Skip to content

Commit db406bd

Browse files
authored
[ts-command-line] Escape % characters in help text. (#5281)
* Add CLI help snapshot tests. * Introduce a failing test with sprintf-style characters. * Escape % characters.
1 parent 7ef45c6 commit db406bd

30 files changed

Lines changed: 564 additions & 94 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/ts-command-line",
5+
"comment": "Escape `%` characters in help text to fix an issue where they were previously interpreted as sprintf-style tokens.",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@rushstack/ts-command-line"
10+
}

libraries/rush-lib/src/api/test/__snapshots__/RushCommandLine.test.ts.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,7 +1191,7 @@ Object {
11911191
"actionName": "import-strings",
11921192
"parameters": Array [
11931193
Object {
1194-
"description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.",
1194+
"description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.",
11951195
"environmentVariable": "RUSH_PARALLELISM",
11961196
"kind": "String",
11971197
"longName": "--parallelism",
@@ -1345,7 +1345,7 @@ Object {
13451345
"actionName": "build",
13461346
"parameters": Array [
13471347
Object {
1348-
"description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.",
1348+
"description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.",
13491349
"environmentVariable": "RUSH_PARALLELISM",
13501350
"kind": "String",
13511351
"longName": "--parallelism",
@@ -1502,7 +1502,7 @@ Object {
15021502
"actionName": "rebuild",
15031503
"parameters": Array [
15041504
Object {
1505-
"description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.",
1505+
"description": "Specifies the maximum number of concurrent processes to launch during a build. The COUNT should be a positive integer, a percentage value (eg. \\"50%\\") or the word \\"max\\" to specify a count that is equal to the number of CPU cores. If this parameter is omitted, then the default value depends on the operating system and number of CPU cores.",
15061506
"environmentVariable": "RUSH_PARALLELISM",
15071507
"kind": "String",
15081508
"longName": "--parallelism",

libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
193193
environmentVariable: EnvironmentVariableNames.RUSH_PARALLELISM,
194194
description:
195195
'Specifies the maximum number of concurrent processes to launch during a build.' +
196-
' The COUNT should be a positive integer, a percentage value (eg. "50%%") or the word "max"' +
196+
' The COUNT should be a positive integer, a percentage value (eg. "50%") or the word "max"' +
197197
' to specify a count that is equal to the number of CPU cores. If this parameter is omitted,' +
198198
' then the default value depends on the operating system and number of CPU cores.'
199199
})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
export function escapeSprintf(input: string): string {
5+
// Escape sprintf-style escape characters
6+
// The primary special character in sprintf format strings is '%'
7+
// which introduces format specifiers like %s, %d, %f, etc.
8+
return input.replace(/%/g, '%%');
9+
}

libraries/ts-command-line/src/providers/CommandLineAction.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type * as argparse from 'argparse';
55

66
import { CommandLineParameterProvider } from './CommandLineParameterProvider';
77
import { CommandLineParserExitError } from './CommandLineParserExitError';
8+
import { escapeSprintf } from '../escapeSprintf';
89

910
/**
1011
* Options for the CommandLineAction constructor.
@@ -83,8 +84,8 @@ export abstract class CommandLineAction extends CommandLineParameterProvider {
8384
*/
8485
public _buildParser(actionsSubParser: argparse.SubParser): void {
8586
this._argumentParser = actionsSubParser.addParser(this.actionName, {
86-
help: this.summary,
87-
description: this.documentation
87+
help: escapeSprintf(this.summary),
88+
description: escapeSprintf(this.documentation)
8889
});
8990

9091
// Monkey-patch the error handling for the action parser

libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { CommandLineStringListParameter } from '../parameters/CommandLineStringL
3939
import { CommandLineRemainder } from '../parameters/CommandLineRemainder';
4040
import { SCOPING_PARAMETER_GROUP } from '../Constants';
4141
import { CommandLineParserExitError } from './CommandLineParserExitError';
42+
import { escapeSprintf } from '../escapeSprintf';
4243

4344
/**
4445
* The result containing the parsed parameter long name and scope. Returned when calling
@@ -858,7 +859,7 @@ export abstract class CommandLineParameterProvider {
858859
// NOTE: Our "environmentVariable" feature takes precedence over argparse's "defaultValue",
859860
// so we have to reimplement that feature.
860861
const argparseOptions: argparse.ArgumentOptions = {
861-
help: finalDescription,
862+
help: escapeSprintf(finalDescription),
862863
dest: parserKey,
863864
metavar: (parameter as CommandLineParameterWithArgument).argumentName,
864865
required,

libraries/ts-command-line/src/providers/CommandLineParser.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { CommandLineParserExitError, CustomArgumentParser } from './CommandLineParserExitError';
1515
import { TabCompleteAction } from './TabCompletionAction';
1616
import { TypeUuid, uuidAlreadyReportedError } from '../TypeUuidLite';
17+
import { escapeSprintf } from '../escapeSprintf';
1718

1819
/**
1920
* Options for the {@link CommandLineParser} constructor.
@@ -75,13 +76,16 @@ export abstract class CommandLineParser extends CommandLineParameterProvider {
7576
this._actions = [];
7677
this._actionsByName = new Map<string, CommandLineAction>();
7778

79+
const { toolFilename, toolDescription, toolEpilog } = options;
80+
7881
this._argumentParser = new CustomArgumentParser({
7982
addHelp: true,
80-
prog: this._options.toolFilename,
81-
description: this._options.toolDescription,
83+
prog: toolFilename,
84+
description: escapeSprintf(toolDescription),
8285
epilog: Colorize.bold(
83-
this._options.toolEpilog ??
84-
`For detailed help about a specific command, use: ${this._options.toolFilename} <command> -h`
86+
escapeSprintf(
87+
toolEpilog ?? `For detailed help about a specific command, use: ${toolFilename} <command> -h`
88+
)
8589
)
8690
});
8791
}

libraries/ts-command-line/src/test/ActionlessParser.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { CommandLineParser } from '../providers/CommandLineParser';
55
import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter';
6+
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';
67

78
class TestCommandLine extends CommandLineParser {
89
public flag: CommandLineFlagParameter;
@@ -27,6 +28,11 @@ class TestCommandLine extends CommandLineParser {
2728
}
2829

2930
describe(`Actionless ${CommandLineParser.name}`, () => {
31+
it('renders help text', () => {
32+
const commandLineParser: TestCommandLine = new TestCommandLine();
33+
ensureHelpTextMatchesSnapshot(commandLineParser);
34+
});
35+
3036
it('parses an empty arg list', async () => {
3137
const commandLineParser: TestCommandLine = new TestCommandLine();
3238

libraries/ts-command-line/src/test/AliasedCommandLineAction.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { CommandLineParameterProvider } from '../providers/CommandLineParam
88
import { AliasCommandLineAction } from '../providers/AliasCommandLineAction';
99
import { CommandLineAction } from '../providers/CommandLineAction';
1010
import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter';
11+
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';
1112

1213
class TestAliasAction extends AliasCommandLineAction {
1314
public done: boolean = false;
@@ -104,6 +105,11 @@ class TestCommandLine extends CommandLineParser {
104105
}
105106

106107
describe(AliasCommandLineAction.name, () => {
108+
it('renders help text', () => {
109+
const commandLineParser: TestCommandLine = new TestCommandLine();
110+
ensureHelpTextMatchesSnapshot(commandLineParser);
111+
});
112+
107113
it('executes the aliased action', async () => {
108114
const commandLineParser: TestCommandLine = new TestCommandLine();
109115
const targetAction: TestAction = commandLineParser.getAction('action') as TestAction;

0 commit comments

Comments
 (0)