Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/ts-command-line",
"comment": "Escape `%` characters in help text to fix an issue where they were previously interpreted as sprintf-style tokens.",
"type": "patch"
}
],
"packageName": "@rushstack/ts-command-line"
}
Original file line number Diff line number Diff line change
Expand Up @@ -1191,7 +1191,7 @@ Object {
"actionName": "import-strings",
"parameters": Array [
Object {
"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.",
"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.",
"environmentVariable": "RUSH_PARALLELISM",
"kind": "String",
"longName": "--parallelism",
Expand Down Expand Up @@ -1345,7 +1345,7 @@ Object {
"actionName": "build",
"parameters": Array [
Object {
"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.",
"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.",
"environmentVariable": "RUSH_PARALLELISM",
"kind": "String",
"longName": "--parallelism",
Expand Down Expand Up @@ -1502,7 +1502,7 @@ Object {
"actionName": "rebuild",
"parameters": Array [
Object {
"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.",
"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.",
"environmentVariable": "RUSH_PARALLELISM",
"kind": "String",
"longName": "--parallelism",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
environmentVariable: EnvironmentVariableNames.RUSH_PARALLELISM,
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"' +
' 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.'
})
Expand Down
9 changes: 9 additions & 0 deletions libraries/ts-command-line/src/escapeSprintf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

export function escapeSprintf(input: string): string {
// Escape sprintf-style escape characters
// The primary special character in sprintf format strings is '%'
// which introduces format specifiers like %s, %d, %f, etc.
return input.replace(/%/g, '%%');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After reviewing the source code for sprintf-js@1.0.3, this looks like a complete solution for ensuring the string will not be modified. 👍👍

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type * as argparse from 'argparse';

import { CommandLineParameterProvider } from './CommandLineParameterProvider';
import { CommandLineParserExitError } from './CommandLineParserExitError';
import { escapeSprintf } from '../escapeSprintf';

/**
* Options for the CommandLineAction constructor.
Expand Down Expand Up @@ -83,8 +84,8 @@ export abstract class CommandLineAction extends CommandLineParameterProvider {
*/
public _buildParser(actionsSubParser: argparse.SubParser): void {
this._argumentParser = actionsSubParser.addParser(this.actionName, {
help: this.summary,
description: this.documentation
help: escapeSprintf(this.summary),
description: escapeSprintf(this.documentation)
});

// Monkey-patch the error handling for the action parser
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { CommandLineStringListParameter } from '../parameters/CommandLineStringL
import { CommandLineRemainder } from '../parameters/CommandLineRemainder';
import { SCOPING_PARAMETER_GROUP } from '../Constants';
import { CommandLineParserExitError } from './CommandLineParserExitError';
import { escapeSprintf } from '../escapeSprintf';

/**
* The result containing the parsed parameter long name and scope. Returned when calling
Expand Down Expand Up @@ -858,7 +859,7 @@ export abstract class CommandLineParameterProvider {
// NOTE: Our "environmentVariable" feature takes precedence over argparse's "defaultValue",
// so we have to reimplement that feature.
const argparseOptions: argparse.ArgumentOptions = {
help: finalDescription,
help: escapeSprintf(finalDescription),
dest: parserKey,
metavar: (parameter as CommandLineParameterWithArgument).argumentName,
required,
Expand Down
12 changes: 8 additions & 4 deletions libraries/ts-command-line/src/providers/CommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { CommandLineParserExitError, CustomArgumentParser } from './CommandLineParserExitError';
import { TabCompleteAction } from './TabCompletionAction';
import { TypeUuid, uuidAlreadyReportedError } from '../TypeUuidLite';
import { escapeSprintf } from '../escapeSprintf';

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

const { toolFilename, toolDescription, toolEpilog } = options;

this._argumentParser = new CustomArgumentParser({
addHelp: true,
prog: this._options.toolFilename,
description: this._options.toolDescription,
prog: toolFilename,
description: escapeSprintf(toolDescription),
epilog: Colorize.bold(
this._options.toolEpilog ??
`For detailed help about a specific command, use: ${this._options.toolFilename} <command> -h`
escapeSprintf(
toolEpilog ?? `For detailed help about a specific command, use: ${toolFilename} <command> -h`
)
)
});
}
Expand Down
6 changes: 6 additions & 0 deletions libraries/ts-command-line/src/test/ActionlessParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { CommandLineParser } from '../providers/CommandLineParser';
import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

class TestCommandLine extends CommandLineParser {
public flag: CommandLineFlagParameter;
Expand All @@ -27,6 +28,11 @@ class TestCommandLine extends CommandLineParser {
}

describe(`Actionless ${CommandLineParser.name}`, () => {
it('renders help text', () => {
const commandLineParser: TestCommandLine = new TestCommandLine();
ensureHelpTextMatchesSnapshot(commandLineParser);
});

it('parses an empty arg list', async () => {
const commandLineParser: TestCommandLine = new TestCommandLine();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CommandLineParameterProvider } from '../providers/CommandLineParam
import { AliasCommandLineAction } from '../providers/AliasCommandLineAction';
import { CommandLineAction } from '../providers/CommandLineAction';
import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

class TestAliasAction extends AliasCommandLineAction {
public done: boolean = false;
Expand Down Expand Up @@ -104,6 +105,11 @@ class TestCommandLine extends CommandLineParser {
}

describe(AliasCommandLineAction.name, () => {
it('renders help text', () => {
const commandLineParser: TestCommandLine = new TestCommandLine();
ensureHelpTextMatchesSnapshot(commandLineParser);
});

it('executes the aliased action', async () => {
const commandLineParser: TestCommandLine = new TestCommandLine();
const targetAction: TestAction = commandLineParser.getAction('action') as TestAction;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { CommandLineStringParameter } from '../parameters/CommandLineString
import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter';
import type { CommandLineParameterProvider } from '../providers/CommandLineParameterProvider';
import { SCOPING_PARAMETER_GROUP } from '../Constants';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

class GenericCommandLine extends CommandLineParser {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -250,6 +251,17 @@ class AbbreviationScopedAction extends ScopedCommandLineAction {
}

describe(`Ambiguous ${CommandLineParser.name}`, () => {
it('renders help text', () => {
const commandLineParser: GenericCommandLine = new GenericCommandLine(
AmbiguousAction,
AbbreviationAction,
AliasAction,
AmbiguousScopedAction,
AbbreviationScopedAction
);
ensureHelpTextMatchesSnapshot(commandLineParser);
});

it('fails to execute when an ambiguous short name is provided', async () => {
const commandLineParser: GenericCommandLine = new GenericCommandLine(AmbiguousAction);

Expand Down
15 changes: 3 additions & 12 deletions libraries/ts-command-line/src/test/CommandLineParameter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
// See LICENSE in the project root for license information.

import * as argparse from 'argparse';
import { AnsiEscape } from '@rushstack/terminal';

import { DynamicCommandLineParser } from '../providers/DynamicCommandLineParser';
import { DynamicCommandLineAction } from '../providers/DynamicCommandLineAction';
import { CommandLineParameterBase } from '../parameters/BaseClasses';
import type { CommandLineParser } from '../providers/CommandLineParser';
import type { CommandLineAction } from '../providers/CommandLineAction';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

interface IExtendedArgumentParser extends argparse.ArgumentParser {
_printMessage: (message: string) => void;
Expand Down Expand Up @@ -174,18 +174,9 @@ describe(CommandLineParameterBase.name, () => {
process.env = existingEnv;
});

it('prints the global help', () => {
it('renders help text', () => {
const commandLineParser: CommandLineParser = createParser();
const helpText: string = AnsiEscape.removeCodes(commandLineParser.renderHelpText());
expect(helpText).toMatchSnapshot();
});

it('prints the action help', () => {
const commandLineParser: CommandLineParser = createParser();
const helpText: string = AnsiEscape.removeCodes(
commandLineParser.getAction('do:the-job').renderHelpText()
);
expect(helpText).toMatchSnapshot();
ensureHelpTextMatchesSnapshot(commandLineParser);
});

it('parses an input with ALL parameters', async () => {
Expand Down
12 changes: 9 additions & 3 deletions libraries/ts-command-line/src/test/CommandLineParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { CommandLineAction } from '../providers/CommandLineAction';
import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter';
import { CommandLineParser } from '../providers/CommandLineParser';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

class TestAction extends CommandLineAction {
public done: boolean = false;
Expand All @@ -12,8 +13,8 @@ class TestAction extends CommandLineAction {
public constructor() {
super({
actionName: 'do:the-job',
summary: 'does the job',
documentation: 'a longer description'
summary: 'does the job with sprintf-style escape characters, 100%',
documentation: 'a longer description with sprintf-style escape characters, 100%'
});

this._flag = this.defineFlagParameter({
Expand All @@ -32,14 +33,19 @@ class TestCommandLine extends CommandLineParser {
public constructor() {
super({
toolFilename: 'example',
toolDescription: 'An example project'
toolDescription: 'An example project with sprintf-style escape characters, 100%'
});

this.addAction(new TestAction());
}
}

describe(CommandLineParser.name, () => {
it('renders help text', () => {
const commandLineParser: TestCommandLine = new TestCommandLine();
ensureHelpTextMatchesSnapshot(commandLineParser);
});

it('executes an action', async () => {
const commandLineParser: TestCommandLine = new TestCommandLine();
commandLineParser._registerDefinedParameters({ parentParameterNames: new Set() });
Expand Down
14 changes: 3 additions & 11 deletions libraries/ts-command-line/src/test/CommandLineRemainder.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { AnsiEscape } from '@rushstack/terminal';

import type { CommandLineAction } from '../providers/CommandLineAction';
import type { CommandLineParser } from '../providers/CommandLineParser';
import { DynamicCommandLineParser } from '../providers/DynamicCommandLineParser';
import { DynamicCommandLineAction } from '../providers/DynamicCommandLineAction';
import { CommandLineRemainder } from '../parameters/CommandLineRemainder';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

function createParser(): DynamicCommandLineParser {
const commandLineParser: DynamicCommandLineParser = new DynamicCommandLineParser({
Expand Down Expand Up @@ -43,16 +42,9 @@ function createParser(): DynamicCommandLineParser {
}

describe(CommandLineRemainder.name, () => {
it('prints the global help', () => {
const commandLineParser: CommandLineParser = createParser();
const helpText: string = AnsiEscape.removeCodes(commandLineParser.renderHelpText());
expect(helpText).toMatchSnapshot();
});

it('prints the action help', () => {
it('renders help text', () => {
const commandLineParser: CommandLineParser = createParser();
const helpText: string = AnsiEscape.removeCodes(commandLineParser.getAction('run').renderHelpText());
expect(helpText).toMatchSnapshot();
ensureHelpTextMatchesSnapshot(commandLineParser);
});

it('parses an action input with remainder', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CommandLineAction } from '../providers/CommandLineAction';
import type { CommandLineStringParameter } from '../parameters/CommandLineStringParameter';
import { CommandLineParser } from '../providers/CommandLineParser';
import type { IScopedLongNameParseResult } from '../providers/CommandLineParameterProvider';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

class GenericCommandLine extends CommandLineParser {
public constructor(action: new () => CommandLineAction) {
Expand Down Expand Up @@ -124,6 +125,8 @@ describe(`Conflicting ${CommandLineParser.name}`, () => {
it('executes an action', async () => {
const commandLineParser: GenericCommandLine = new GenericCommandLine(TestAction);

ensureHelpTextMatchesSnapshot(commandLineParser);

await commandLineParser.executeAsync([
'do:the-job',
'--scope1:arg',
Expand All @@ -147,6 +150,8 @@ describe(`Conflicting ${CommandLineParser.name}`, () => {
it('parses the scope out of a long name correctly', async () => {
const commandLineParser: GenericCommandLine = new GenericCommandLine(TestAction);

ensureHelpTextMatchesSnapshot(commandLineParser);

let result: IScopedLongNameParseResult = commandLineParser.parseScopedLongName('--scope1:arg');
expect(result.scope).toEqual('scope1');
expect(result.longName).toEqual('--arg');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { DynamicCommandLineParser } from '../providers/DynamicCommandLineParser';
import { DynamicCommandLineAction } from '../providers/DynamicCommandLineAction';
import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

describe(DynamicCommandLineParser.name, () => {
it('parses an action', async () => {
Expand All @@ -23,6 +24,8 @@ describe(DynamicCommandLineParser.name, () => {
description: 'The flag'
});

ensureHelpTextMatchesSnapshot(commandLineParser);

await commandLineParser.executeAsync(['do:the-job', '--flag']);

expect(commandLineParser.selectedAction).toEqual(action);
Expand Down
10 changes: 2 additions & 8 deletions libraries/ts-command-line/src/test/EndToEndTest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// See LICENSE in the project root for license information.

import type { ChildProcess } from 'node:child_process';
import { AnsiEscape } from '@rushstack/terminal';
import { Executable } from '@rushstack/node-core-library';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

const TEST_CLI_PATH: string = `${__dirname}/test-cli/start`;

Expand Down Expand Up @@ -36,13 +36,7 @@ describe('end-to-end test', () => {

const parser = new WidgetCommandLine();

const globalHelpText: string = AnsiEscape.formatForTests(parser.renderHelpText());
expect(globalHelpText).toMatchSnapshot('global help');

for (const action of parser.actions) {
const actionHelpText: string = AnsiEscape.formatForTests(action.renderHelpText());
expect(actionHelpText).toMatchSnapshot(action.actionName);
}
ensureHelpTextMatchesSnapshot(parser);
});

describe('execution tests', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CommandLineStringParameter } from '../parameters/CommandLineString
import { CommandLineParser } from '../providers/CommandLineParser';
import type { CommandLineParameterProvider } from '../providers/CommandLineParameterProvider';
import type { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter';
import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities';

class TestScopedAction extends ScopedCommandLineAction {
public done: boolean = false;
Expand Down Expand Up @@ -67,6 +68,11 @@ class TestCommandLine extends CommandLineParser {
}

describe(CommandLineParser.name, () => {
it('renders help text', () => {
const commandLineParser: TestCommandLine = new TestCommandLine();
ensureHelpTextMatchesSnapshot(commandLineParser);
});

it('throws on unknown scoped arg', async () => {
const commandLineParser: TestCommandLine = new TestCommandLine();
const args: string[] = ['scoped-action', '--scope', 'foo', '--', '--scoped-bar', 'baz'];
Expand Down
Loading
Loading