diff --git a/common/changes/@rushstack/ts-command-line/user-danade-ScopedCommandLine_2022-04-21-22-00.json b/common/changes/@rushstack/ts-command-line/user-danade-ScopedCommandLine_2022-04-21-22-00.json new file mode 100644 index 00000000000..7f0cc08de2a --- /dev/null +++ b/common/changes/@rushstack/ts-command-line/user-danade-ScopedCommandLine_2022-04-21-22-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/ts-command-line", + "comment": "Add ScopedCommandLineAction class, which allows for the definition of actions that have dynamic arguments whose definition depends on a provided scope. See https://github.com/microsoft/rushstack/pull/3364", + "type": "minor" + } + ], + "packageName": "@rushstack/ts-command-line" +} \ No newline at end of file diff --git a/common/reviews/api/ts-command-line.api.md b/common/reviews/api/ts-command-line.api.md index aea2b69eec2..c9964e7a8ab 100644 --- a/common/reviews/api/ts-command-line.api.md +++ b/common/reviews/api/ts-command-line.api.md @@ -20,7 +20,7 @@ export abstract class CommandLineAction extends CommandLineParameterProvider { protected abstract onDefineParameters(): void; protected abstract onExecute(): Promise; // @internal - _processParsedData(data: _ICommandLineParserData): void; + _processParsedData(parserOptions: ICommandLineParserOptions, data: _ICommandLineParserData): void; readonly summary: string; } @@ -115,6 +115,7 @@ export abstract class CommandLineParameter { _getSupplementaryNotes(supplementaryNotes: string[]): void; abstract get kind(): CommandLineParameterKind; readonly longName: string; + readonly parameterGroup: string | typeof SCOPING_PARAMETER_GROUP | undefined; // @internal _parserKey: string | undefined; protected reportInvalidData(data: any): never; @@ -148,6 +149,8 @@ export abstract class CommandLineParameterProvider { defineFlagParameter(definition: ICommandLineFlagDefinition): CommandLineFlagParameter; defineIntegerListParameter(definition: ICommandLineIntegerListDefinition): CommandLineIntegerListParameter; defineIntegerParameter(definition: ICommandLineIntegerDefinition): CommandLineIntegerParameter; + // @internal (undocumented) + protected _defineParameter(parameter: CommandLineParameter): void; defineStringListParameter(definition: ICommandLineStringListDefinition): CommandLineStringListParameter; defineStringParameter(definition: ICommandLineStringDefinition): CommandLineStringParameter; // @internal @@ -164,9 +167,10 @@ export abstract class CommandLineParameterProvider { get parameters(): ReadonlyArray; get parametersProcessed(): boolean; // @internal (undocumented) - protected _processParsedData(data: _ICommandLineParserData): void; + protected _processParsedData(parserOptions: ICommandLineParserOptions, data: _ICommandLineParserData): void; get remainder(): CommandLineRemainder | undefined; renderHelpText(): string; + renderUsageText(): string; } // @public @@ -249,6 +253,7 @@ export class DynamicCommandLineParser extends CommandLineParser { export interface IBaseCommandLineDefinition { description: string; environmentVariable?: string; + parameterGroup?: string | typeof SCOPING_PARAMETER_GROUP; parameterLongName: string; parameterShortName?: string; required?: boolean; @@ -306,6 +311,7 @@ export interface _ICommandLineParserData { export interface ICommandLineParserOptions { enableTabCompletionAction?: boolean; toolDescription: string; + toolEpilog?: string; toolFilename: string; } @@ -323,4 +329,23 @@ export interface ICommandLineStringDefinition extends IBaseCommandLineDefinition export interface ICommandLineStringListDefinition extends IBaseCommandLineDefinitionWithArgument { } +// @public +export abstract class ScopedCommandLineAction extends CommandLineAction { + constructor(options: ICommandLineActionOptions); + // @internal (undocumented) + protected _defineParameter(parameter: CommandLineParameter): void; + // @internal + _execute(): Promise; + // @internal + protected _getScopedCommandLineParser(): CommandLineParser; + protected onDefineParameters(): void; + protected abstract onDefineScopedParameters(scopedParameterProvider: CommandLineParameterProvider): void; + protected abstract onDefineUnscopedParameters(): void; + protected abstract onExecute(): Promise; + get parameters(): ReadonlyArray; + // @internal + _processParsedData(parserOptions: ICommandLineParserOptions, data: _ICommandLineParserData): void; + static readonly ScopingParameterGroup: typeof SCOPING_PARAMETER_GROUP; +} + ``` diff --git a/libraries/ts-command-line/src/Constants.ts b/libraries/ts-command-line/src/Constants.ts index 7a5a285280e..5b2d48b00c8 100644 --- a/libraries/ts-command-line/src/Constants.ts +++ b/libraries/ts-command-line/src/Constants.ts @@ -12,3 +12,5 @@ export const enum CommandLineConstants { */ TabCompletionActionName = 'tab-complete' } + +export const SCOPING_PARAMETER_GROUP: unique symbol = Symbol('scoping'); diff --git a/libraries/ts-command-line/src/index.ts b/libraries/ts-command-line/src/index.ts index 2c82b011bc0..1cd47635a41 100644 --- a/libraries/ts-command-line/src/index.ts +++ b/libraries/ts-command-line/src/index.ts @@ -8,6 +8,8 @@ */ export { CommandLineAction, ICommandLineActionOptions } from './providers/CommandLineAction'; +export { DynamicCommandLineAction } from './providers/DynamicCommandLineAction'; +export { ScopedCommandLineAction } from './providers/ScopedCommandLineAction'; export { IBaseCommandLineDefinition, @@ -43,9 +45,6 @@ export { } from './providers/CommandLineParameterProvider'; export { ICommandLineParserOptions, CommandLineParser } from './providers/CommandLineParser'; - -export { DynamicCommandLineAction } from './providers/DynamicCommandLineAction'; - export { DynamicCommandLineParser } from './providers/DynamicCommandLineParser'; export { CommandLineConstants } from './Constants'; diff --git a/libraries/ts-command-line/src/parameters/BaseClasses.ts b/libraries/ts-command-line/src/parameters/BaseClasses.ts index 8b61cb49a08..1950696fae6 100644 --- a/libraries/ts-command-line/src/parameters/BaseClasses.ts +++ b/libraries/ts-command-line/src/parameters/BaseClasses.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import type { SCOPING_PARAMETER_GROUP } from '../Constants'; import { IBaseCommandLineDefinition, IBaseCommandLineDefinitionWithArgument } from './CommandLineDefinition'; /** @@ -53,6 +54,9 @@ export abstract class CommandLineParameter { /** {@inheritDoc IBaseCommandLineDefinition.parameterShortName} */ public readonly shortName: string | undefined; + /** {@inheritDoc IBaseCommandLineDefinition.parameterGroup} */ + public readonly parameterGroup: string | typeof SCOPING_PARAMETER_GROUP | undefined; + /** {@inheritDoc IBaseCommandLineDefinition.description} */ public readonly description: string; @@ -69,6 +73,7 @@ export abstract class CommandLineParameter { public constructor(definition: IBaseCommandLineDefinition) { this.longName = definition.parameterLongName; this.shortName = definition.parameterShortName; + this.parameterGroup = definition.parameterGroup; this.description = definition.description; this.required = !!definition.required; this.environmentVariable = definition.environmentVariable; diff --git a/libraries/ts-command-line/src/parameters/CommandLineDefinition.ts b/libraries/ts-command-line/src/parameters/CommandLineDefinition.ts index 3d7913efb2c..a5322d02708 100644 --- a/libraries/ts-command-line/src/parameters/CommandLineDefinition.ts +++ b/libraries/ts-command-line/src/parameters/CommandLineDefinition.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import type { SCOPING_PARAMETER_GROUP } from '../Constants'; + /** * For use with CommandLineParser, this interface represents a generic command-line parameter * @@ -17,6 +19,11 @@ export interface IBaseCommandLineDefinition { */ parameterShortName?: string; + /** + * An optional parameter group name, shown when invoking the tool with "--help" + */ + parameterGroup?: string | typeof SCOPING_PARAMETER_GROUP; + /** * Documentation for the parameter that will be shown when invoking the tool with "--help" */ diff --git a/libraries/ts-command-line/src/providers/CommandLineAction.ts b/libraries/ts-command-line/src/providers/CommandLineAction.ts index a0bd8deb4d9..e9f5fb874ea 100644 --- a/libraries/ts-command-line/src/providers/CommandLineAction.ts +++ b/libraries/ts-command-line/src/providers/CommandLineAction.ts @@ -2,7 +2,9 @@ // See LICENSE in the project root for license information. import * as argparse from 'argparse'; + import { CommandLineParameterProvider, ICommandLineParserData } from './CommandLineParameterProvider'; +import type { ICommandLineParserOptions } from './CommandLineParser'; /** * Options for the CommandLineAction constructor. @@ -90,8 +92,8 @@ export abstract class CommandLineAction extends CommandLineParameterProvider { * This is called internally by CommandLineParser.execute() * @internal */ - public _processParsedData(data: ICommandLineParserData): void { - super._processParsedData(data); + public _processParsedData(parserOptions: ICommandLineParserOptions, data: ICommandLineParserData): void { + super._processParsedData(parserOptions, data); } /** diff --git a/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts b/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts index 48e0818d01f..7e02763190b 100644 --- a/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts +++ b/libraries/ts-command-line/src/providers/CommandLineParameterProvider.ts @@ -2,7 +2,8 @@ // See LICENSE in the project root for license information. import * as argparse from 'argparse'; -import { + +import type { ICommandLineChoiceDefinition, ICommandLineChoiceListDefinition, ICommandLineIntegerDefinition, @@ -12,6 +13,7 @@ import { ICommandLineStringListDefinition, ICommandLineRemainderDefinition } from '../parameters/CommandLineDefinition'; +import type { ICommandLineParserOptions } from './CommandLineParser'; import { CommandLineParameter, CommandLineParameterWithArgument, @@ -25,6 +27,7 @@ import { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter import { CommandLineStringParameter } from '../parameters/CommandLineStringParameter'; import { CommandLineStringListParameter } from '../parameters/CommandLineStringListParameter'; import { CommandLineRemainder } from '../parameters/CommandLineRemainder'; +import { SCOPING_PARAMETER_GROUP } from '../Constants'; /** * This is the argparse result data object @@ -46,6 +49,7 @@ export abstract class CommandLineParameterProvider { private _parameters: CommandLineParameter[]; private _parametersByLongName: Map; + private _parameterGroupsByName: Map; private _parametersProcessed: boolean; private _remainder: CommandLineRemainder | undefined; @@ -53,7 +57,8 @@ export abstract class CommandLineParameterProvider { // Third party code should not inherit subclasses or call this constructor public constructor() { this._parameters = []; - this._parametersByLongName = new Map(); + this._parametersByLongName = new Map(); + this._parameterGroupsByName = new Map(); this._parametersProcessed = false; } @@ -207,6 +212,7 @@ export abstract class CommandLineParameterProvider { public getIntegerListParameter(parameterLongName: string): CommandLineIntegerListParameter { return this._getParameter(parameterLongName, CommandLineParameterKind.IntegerList); } + /** * Defines a command-line parameter whose argument is a single text string. * @@ -297,6 +303,13 @@ export abstract class CommandLineParameterProvider { return this._getArgumentParser().formatHelp(); } + /** + * Generates the command-line usage text. + */ + public renderUsageText(): string { + return this._getArgumentParser().formatUsage(); + } + /** * Returns a object which maps the long name of each parameter in this.parameters * to the stringified form of its value. This is useful for logging telemetry, but @@ -349,7 +362,7 @@ export abstract class CommandLineParameterProvider { protected abstract _getArgumentParser(): argparse.ArgumentParser; /** @internal */ - protected _processParsedData(data: ICommandLineParserData): void { + protected _processParsedData(parserOptions: ICommandLineParserOptions, data: ICommandLineParserData): void { if (this._parametersProcessed) { throw new Error('Command Line Parser Data was already processed'); } @@ -367,28 +380,8 @@ export abstract class CommandLineParameterProvider { this._parametersProcessed = true; } - private _generateKey(): string { - return 'key_' + (CommandLineParameterProvider._keyCounter++).toString(); - } - - private _getParameter( - parameterLongName: string, - expectedKind: CommandLineParameterKind - ): T { - const parameter: CommandLineParameter | undefined = this._parametersByLongName.get(parameterLongName); - if (!parameter) { - throw new Error(`The parameter "${parameterLongName}" is not defined`); - } - if (parameter.kind !== expectedKind) { - throw new Error( - `The parameter "${parameterLongName}" is of type "${CommandLineParameterKind[parameter.kind]}"` + - ` whereas the caller was expecting "${CommandLineParameterKind[expectedKind]}".` - ); - } - return parameter as T; - } - - private _defineParameter(parameter: CommandLineParameter): void { + /** @internal */ + protected _defineParameter(parameter: CommandLineParameter): void { if (this._remainder) { throw new Error( 'defineCommandLineRemainder() was already called for this provider;' + @@ -455,10 +448,32 @@ export abstract class CommandLineParameterProvider { break; } - const argumentParser: argparse.ArgumentParser = this._getArgumentParser(); - argumentParser.addArgument(names, { ...argparseOptions }); + let argumentGroup: argparse.ArgumentGroup | undefined; + if (parameter.parameterGroup) { + argumentGroup = this._parameterGroupsByName.get(parameter.parameterGroup); + if (!argumentGroup) { + let parameterGroupName: string; + if (typeof parameter.parameterGroup === 'string') { + parameterGroupName = parameter.parameterGroup; + } else if (parameter.parameterGroup === SCOPING_PARAMETER_GROUP) { + parameterGroupName = 'scoping'; + } else { + throw new Error('Unexpected parameter group: ' + parameter.parameterGroup); + } + + argumentGroup = this._getArgumentParser().addArgumentGroup({ + title: `Optional ${parameterGroupName} arguments` + }); + this._parameterGroupsByName.set(parameter.parameterGroup, argumentGroup); + } + } else { + argumentGroup = this._getArgumentParser(); + } + + argumentGroup.addArgument(names, { ...argparseOptions }); + if (parameter.undocumentedSynonyms && parameter.undocumentedSynonyms.length > 0) { - argumentParser.addArgument(parameter.undocumentedSynonyms, { + argumentGroup.addArgument(parameter.undocumentedSynonyms, { ...argparseOptions, help: argparse.Const.SUPPRESS }); @@ -467,4 +482,25 @@ export abstract class CommandLineParameterProvider { this._parameters.push(parameter); this._parametersByLongName.set(parameter.longName, parameter); } + + private _generateKey(): string { + return 'key_' + (CommandLineParameterProvider._keyCounter++).toString(); + } + + private _getParameter( + parameterLongName: string, + expectedKind: CommandLineParameterKind + ): T { + const parameter: CommandLineParameter | undefined = this._parametersByLongName.get(parameterLongName); + if (!parameter) { + throw new Error(`The parameter "${parameterLongName}" is not defined`); + } + if (parameter.kind !== expectedKind) { + throw new Error( + `The parameter "${parameterLongName}" is of type "${CommandLineParameterKind[parameter.kind]}"` + + ` whereas the caller was expecting "${CommandLineParameterKind[expectedKind]}".` + ); + } + return parameter as T; + } } diff --git a/libraries/ts-command-line/src/providers/CommandLineParser.ts b/libraries/ts-command-line/src/providers/CommandLineParser.ts index f14888886ec..25fc95342b1 100644 --- a/libraries/ts-command-line/src/providers/CommandLineParser.ts +++ b/libraries/ts-command-line/src/providers/CommandLineParser.ts @@ -24,6 +24,12 @@ export interface ICommandLineParserOptions { */ toolDescription: string; + /** + * An optional string to append at the end of the "--help" main page. If not provided, an epilog + * will be automatically generated based on the toolFilename. + */ + toolEpilog?: string; + /** * Set to true to auto-define a tab completion action. False by default. */ @@ -68,7 +74,8 @@ export abstract class CommandLineParser extends CommandLineParameterProvider { prog: this._options.toolFilename, description: this._options.toolDescription, epilog: colors.bold( - `For detailed help about a specific command, use: ${this._options.toolFilename} -h` + this._options.toolEpilog ?? + `For detailed help about a specific command, use: ${this._options.toolFilename} -h` ) }); @@ -194,19 +201,21 @@ export abstract class CommandLineParser extends CommandLineParameterProvider { // 0=node.exe, 1=script name args = process.argv.slice(2); } - if (args.length === 0) { + if (this.actions.length > 0 && args.length === 0) { + // Parsers that use actions should print help when 0 args are provided. Allow + // actionless parsers to continue on zero args. this._argumentParser.printHelp(); return; } const data: ICommandLineParserData = this._argumentParser.parseArgs(args); - this._processParsedData(data); + this._processParsedData(this._options, data); for (const action of this._actions) { if (action.actionName === data.action) { this.selectedAction = action; - action._processParsedData(data); + action._processParsedData(this._options, data); break; } } diff --git a/libraries/ts-command-line/src/providers/ScopedCommandLineAction.ts b/libraries/ts-command-line/src/providers/ScopedCommandLineAction.ts new file mode 100644 index 00000000000..20c67323f41 --- /dev/null +++ b/libraries/ts-command-line/src/providers/ScopedCommandLineAction.ts @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { SCOPING_PARAMETER_GROUP } from '../Constants'; +import { CommandLineAction, ICommandLineActionOptions } from './CommandLineAction'; +import { CommandLineParser, ICommandLineParserOptions } from './CommandLineParser'; +import { CommandLineParserExitError } from './CommandLineParserExitError'; +import type { CommandLineParameter } from '../parameters/BaseClasses'; +import type { CommandLineParameterProvider, ICommandLineParserData } from './CommandLineParameterProvider'; + +interface IInternalScopedCommandLineParserOptions extends ICommandLineParserOptions { + readonly actionOptions: ICommandLineActionOptions; + readonly unscopedActionParameters: ReadonlyArray; + readonly onDefineScopedParameters: (commandLineParameterProvider: CommandLineParameterProvider) => void; +} + +/** + * A CommandLineParser used exclusively to parse the scoped command-line parameters + * for a ScopedCommandLineAction. + */ +class InternalScopedCommandLineParser extends CommandLineParser { + private _canExecute: boolean; + private _internalOptions: IInternalScopedCommandLineParserOptions; + + public get canExecute(): boolean { + return this._canExecute; + } + + public constructor(options: IInternalScopedCommandLineParserOptions) { + // We can run the parser directly because we are not going to use it for any other actions, + // so construct a special options object to make the "--help" text more useful. + const scopingArgs: string[] = []; + for (const parameter of options.unscopedActionParameters) { + parameter.appendToArgList(scopingArgs); + } + const unscopedToolName: string = `${options.toolFilename} ${options.actionOptions.actionName}`; + const scopedCommandLineParserOptions: ICommandLineParserOptions = { + toolFilename: `${unscopedToolName}${scopingArgs.length ? ' ' + scopingArgs.join(' ') : ''} --`, + toolDescription: options.actionOptions.documentation, + toolEpilog: `For more information on available unscoped parameters, use "${unscopedToolName} --help"`, + enableTabCompletionAction: false + }; + + super(scopedCommandLineParserOptions); + this._canExecute = false; + this._internalOptions = options; + this._internalOptions.onDefineScopedParameters(this); + } + + protected onDefineParameters(): void { + // No-op. Parameters are manually defined in the constructor. + } + + protected async onExecute(): Promise { + // override + // Only set if we made it this far, which may not be the case if an error occurred or + // if '--help' was specified. + this._canExecute = true; + } +} + +/** + * Represents a sub-command that is part of the CommandLineParser command-line. + * Applications should create subclasses of ScopedCommandLineAction corresponding to + * each action that they want to expose. + * + * The action name should be comprised of lower case words separated by hyphens + * or colons. The name should include an English verb (e.g. "deploy"). Use a + * hyphen to separate words (e.g. "upload-docs"). A group of related commands + * can be prefixed with a colon (e.g. "docs:generate", "docs:deploy", + * "docs:serve", etc). + * + * Scoped commands allow for different parameters to be specified for different + * provided scoping values. For example, the "scoped-action --scope A" command + * may allow for different scoped arguments to be specified than the "scoped-action + * --scope B" command. + * + * Scoped arguments are specified after the "--" pseudo-argument. For example, + * "scoped-action --scope A -- --scopedFoo --scopedBar". + * + * @public + */ +export abstract class ScopedCommandLineAction extends CommandLineAction { + private _options: ICommandLineActionOptions; + private _scopingParameters: CommandLineParameter[]; + private _unscopedParserOptions: ICommandLineParserOptions | undefined; + private _scopedCommandLineParser: InternalScopedCommandLineParser | undefined; + + /** + * The required group name to apply to all scoping parameters. At least one parameter + * must be defined with this group name. + */ + public static readonly ScopingParameterGroup: typeof SCOPING_PARAMETER_GROUP = SCOPING_PARAMETER_GROUP; + + public constructor(options: ICommandLineActionOptions) { + super(options); + + this._options = options; + this._scopingParameters = []; + } + + /** + * {@inheritDoc CommandLineParameterProvider.parameters} + */ + public get parameters(): ReadonlyArray { + if (this._scopedCommandLineParser) { + return [...super.parameters, ...this._scopedCommandLineParser.parameters]; + } else { + return super.parameters; + } + } + + /** + * {@inheritdoc CommandLineAction._processParsedData} + * @internal + */ + public _processParsedData(parserOptions: ICommandLineParserOptions, data: ICommandLineParserData): void { + // override + super._processParsedData(parserOptions, data); + + this._unscopedParserOptions = parserOptions; + + // Generate the scoped parser using the parent parser information. We can only create this after we + // have parsed the data, since the parameter values are used during construction. + this._scopedCommandLineParser = new InternalScopedCommandLineParser({ + ...parserOptions, + actionOptions: this._options, + unscopedActionParameters: this.parameters, + onDefineScopedParameters: this.onDefineScopedParameters.bind(this) + }); + } + + /** + * {@inheritdoc CommandLineAction._execute} + * @internal + */ + public async _execute(): Promise { + // override + if (!this._unscopedParserOptions || !this._scopedCommandLineParser) { + throw new Error('The CommandLineAction parameters must be processed before execution.'); + } + if (!this.remainder) { + throw new Error('CommandLineAction.onDefineParameters must be called before execution.'); + } + + // The '--' argument is required to separate the action parameters from the scoped parameters, + // so it needs to be trimmed. If remainder values are provided but no '--' is found, then throw. + const scopedArgs: string[] = []; + if (this.remainder.values.length) { + if (this.remainder.values[0] !== '--') { + // Immitate argparse behavior and log out usage text before throwing. + console.log(this.renderUsageText()); + throw new CommandLineParserExitError( + // argparse sets exit code 2 for invalid arguments + 2, + // model the message off of the built-in "unrecognized arguments" message + `${this._unscopedParserOptions.toolFilename} ${this.actionName}: error: Unrecognized ` + + `arguments: ${this.remainder.values[0]}.` + ); + } + scopedArgs.push(...this.remainder.values.slice(1)); + } + + // Call the scoped parser using only the scoped args to handle parsing + await this._scopedCommandLineParser.executeWithoutErrorHandling(scopedArgs); + + // Only call execute if the parser reached the execute stage. This may not be true if + // the parser exited early due to a specified '--help' parameter. + if (this._scopedCommandLineParser.canExecute) { + await super._execute(); + } + + return; + } + + /** + * {@inheritdoc CommandLineParameterProvider.onDefineParameters} + */ + protected onDefineParameters(): void { + this.onDefineUnscopedParameters(); + + if (!this._scopingParameters.length) { + throw new Error( + 'No scoping parameters defined. At least one scoping parameter must be defined. ' + + 'Scoping parameters are defined by setting the parameterGroupName to ' + + 'ScopedCommandLineAction.ScopingParameterGroupName.' + ); + } + if (this.remainder) { + throw new Error( + 'Unscoped remainder parameters are not allowed. Remainder parameters can only be defined on ' + + 'the scoped parameter provider in onDefineScopedParameters().' + ); + } + + // Consume the remainder of the command-line, which will later be passed the scoped parser. + // This will also prevent developers from calling this.defineCommandLineRemainder(...) since + // we will have already defined it. + this.defineCommandLineRemainder({ + description: + 'Scoped parameters. Must be prefixed with "--", ex. "-- --scopedParameter ' + + 'foo --scopedFlag". For more information on available scoped parameters, use "-- --help".' + }); + } + + /** + * Retrieves the scoped CommandLineParser, which is populated after the ScopedCommandLineAction is executed. + * @internal + */ + protected _getScopedCommandLineParser(): CommandLineParser { + if (!this._scopedCommandLineParser) { + throw new Error('The scoped CommandLineParser is only populated after the action is executed.'); + } + return this._scopedCommandLineParser; + } + + /** @internal */ + protected _defineParameter(parameter: CommandLineParameter): void { + super._defineParameter(parameter); + if (parameter.parameterGroup === ScopedCommandLineAction.ScopingParameterGroup) { + this._scopingParameters.push(parameter); + } + } + + /** + * The child class should implement this hook to define its unscoped command-line parameters, + * e.g. by calling defineFlagParameter(). At least one scoping parameter must be defined. + * Scoping parameters are defined by setting the parameterGroupName to + * ScopedCommandLineAction.ScopingParameterGroupName. + */ + protected abstract onDefineUnscopedParameters(): void; + + /** + * The child class should implement this hook to define its scoped command-line + * parameters, e.g. by calling scopedParameterProvider.defineFlagParameter(). These + * parameters will only be available if the action is invoked with a scope. + * + * @remarks + * onDefineScopedParameters is called after the unscoped parameters have been parsed. + * The values they provide can be used to vary the defined scope parameters. + */ + protected abstract onDefineScopedParameters(scopedParameterProvider: CommandLineParameterProvider): void; + + /** + * {@inheritDoc CommandLineAction.onExecute} + */ + protected abstract onExecute(): Promise; +} diff --git a/libraries/ts-command-line/src/test/ActionlessParser.test.ts b/libraries/ts-command-line/src/test/ActionlessParser.test.ts index dea5cdc0a64..71c0e7edd16 100644 --- a/libraries/ts-command-line/src/test/ActionlessParser.test.ts +++ b/libraries/ts-command-line/src/test/ActionlessParser.test.ts @@ -6,6 +6,7 @@ import { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter class TestCommandLine extends CommandLineParser { public flag!: CommandLineFlagParameter; + public done: boolean = false; public constructor() { super({ @@ -14,6 +15,11 @@ class TestCommandLine extends CommandLineParser { }); } + protected async onExecute(): Promise { + await super.onExecute(); + this.done = true; + } + protected onDefineParameters(): void { this.flag = this.defineFlagParameter({ parameterLongName: '--flag', @@ -23,11 +29,22 @@ class TestCommandLine extends CommandLineParser { } describe(`Actionless ${CommandLineParser.name}`, () => { + it('parses an empty arg list', async () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + + await commandLineParser.execute([]); + + expect(commandLineParser.done).toBe(true); + expect(commandLineParser.selectedAction).toBeUndefined(); + expect(commandLineParser.flag.value).toBe(false); + }); + it('parses a flag', async () => { const commandLineParser: TestCommandLine = new TestCommandLine(); await commandLineParser.execute(['--flag']); + expect(commandLineParser.done).toBe(true); expect(commandLineParser.selectedAction).toBeUndefined(); expect(commandLineParser.flag.value).toBe(true); }); @@ -41,6 +58,7 @@ describe(`Actionless ${CommandLineParser.name}`, () => { await commandLineParser.execute(['--flag', 'the', 'remaining', 'args']); + expect(commandLineParser.done).toBe(true); expect(commandLineParser.selectedAction).toBeUndefined(); expect(commandLineParser.flag.value).toBe(true); expect(commandLineParser.remainder!.values).toEqual(['the', 'remaining', 'args']); diff --git a/libraries/ts-command-line/src/test/CommandLineParameter.test.ts b/libraries/ts-command-line/src/test/CommandLineParameter.test.ts index 08c104e9d90..af389928f9a 100644 --- a/libraries/ts-command-line/src/test/CommandLineParameter.test.ts +++ b/libraries/ts-command-line/src/test/CommandLineParameter.test.ts @@ -361,31 +361,34 @@ describe(CommandLineParameter.name, () => { return commandLineParser; } - it('raises an error if env var value is not valid json', () => { + it('raises an error if env var value is not valid json', async () => { const commandLineParser: CommandLineParser = createHelloWorldParser(); const args: string[] = ['hello-world']; process.env.ENV_COLOR = '[u'; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - expect(commandLineParser.executeWithoutErrorHandling(args)).rejects.toThrowErrorMatchingSnapshot(); + await expect( + commandLineParser.executeWithoutErrorHandling(args) + ).rejects.toThrowErrorMatchingSnapshot(); }); - it('raises an error if env var value is json containing non-scalars', () => { + it('raises an error if env var value is json containing non-scalars', async () => { const commandLineParser: CommandLineParser = createHelloWorldParser(); const args: string[] = ['hello-world']; process.env.ENV_COLOR = '[{}]'; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - expect(commandLineParser.executeWithoutErrorHandling(args)).rejects.toThrowErrorMatchingSnapshot(); + await expect( + commandLineParser.executeWithoutErrorHandling(args) + ).rejects.toThrowErrorMatchingSnapshot(); }); - it('raises an error if env var value is not a valid choice', () => { + it('raises an error if env var value is not a valid choice', async () => { const commandLineParser: CommandLineParser = createHelloWorldParser(); const args: string[] = ['hello-world']; process.env.ENV_COLOR = 'oblong'; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - expect(commandLineParser.executeWithoutErrorHandling(args)).rejects.toThrowErrorMatchingSnapshot(); + await expect( + commandLineParser.executeWithoutErrorHandling(args) + ).rejects.toThrowErrorMatchingSnapshot(); }); }); }); diff --git a/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts b/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts index 62bd8cdc949..0becb8dede7 100644 --- a/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts +++ b/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts @@ -73,4 +73,25 @@ describe(CommandLineRemainder.name, () => { expect(copiedArgs).toMatchSnapshot(); }); + + it('parses an action input with remainder flagged options', async () => { + const commandLineParser: CommandLineParser = createParser(); + const action: CommandLineAction = commandLineParser.getAction('run'); + const args: string[] = ['run', '--title', 'The title', '--', '--the', 'remaining', '--args']; + + await commandLineParser.execute(args); + + expect(commandLineParser.selectedAction).toBe(action); + + const copiedArgs: string[] = []; + for (const parameter of action.parameters) { + copiedArgs.push(`### ${parameter.longName} output: ###`); + parameter.appendToArgList(copiedArgs); + } + + copiedArgs.push(`### remainder output: ###`); + action.remainder!.appendToArgList(copiedArgs); + + expect(copiedArgs).toMatchSnapshot(); + }); }); diff --git a/libraries/ts-command-line/src/test/ScopedCommandLineAction.test.ts b/libraries/ts-command-line/src/test/ScopedCommandLineAction.test.ts new file mode 100644 index 00000000000..05336946ef4 --- /dev/null +++ b/libraries/ts-command-line/src/test/ScopedCommandLineAction.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as colors from 'colors'; + +import { ScopedCommandLineAction } from '../providers/ScopedCommandLineAction'; +import { CommandLineStringParameter } from '../parameters/CommandLineStringParameter'; +import { CommandLineParser } from '../providers/CommandLineParser'; +import { CommandLineParameterProvider } from '../providers/CommandLineParameterProvider'; +import { CommandLineFlagParameter } from '../parameters/CommandLineFlagParameter'; + +class TestScopedAction extends ScopedCommandLineAction { + public done: boolean = false; + public scopedValue: string | undefined; + private _verboseArg!: CommandLineFlagParameter; + private _scopeArg!: CommandLineStringParameter; + private _scopedArg: CommandLineStringParameter | undefined; + + public constructor() { + super({ + actionName: 'scoped-action', + summary: 'does the scoped action', + documentation: 'a longer description' + }); + } + + protected async onExecute(): Promise { + if (this._scopedArg) { + expect(this._scopedArg.longName).toBe(`--scoped-${this._scopeArg.value}`); + this.scopedValue = this._scopedArg.value; + } + this.done = true; + } + + protected onDefineUnscopedParameters(): void { + this._verboseArg = this.defineFlagParameter({ + parameterLongName: '--verbose', + description: 'A flag parameter.' + }); + + this._scopeArg = this.defineStringParameter({ + parameterLongName: '--scope', + parameterGroup: ScopedCommandLineAction.ScopingParameterGroup, + argumentName: 'SCOPE', + description: 'The scope' + }); + } + + protected onDefineScopedParameters(scopedParameterProvider: CommandLineParameterProvider): void { + if (this._scopeArg.value) { + this._scopedArg = scopedParameterProvider.defineStringParameter({ + parameterLongName: `--scoped-${this._scopeArg.value}`, + argumentName: 'SCOPED', + description: 'The scoped argument.' + }); + } + } +} + +class TestCommandLine extends CommandLineParser { + public constructor() { + super({ + toolFilename: 'example', + toolDescription: 'An example project' + }); + + this.addAction(new TestScopedAction()); + } + + protected onDefineParameters(): void { + // no parameters + } +} + +describe(CommandLineParser.name, () => { + it('throws on unknown scoped arg', async () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + const args: string[] = ['scoped-action', '--scope', 'foo', '--', '--scoped-bar', 'baz']; + + await expect(commandLineParser.executeWithoutErrorHandling(args)).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('throws on missing positional arg divider with unknown positional args', async () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + const args: string[] = ['scoped-action', '--scope', 'foo', 'bar']; + + await expect(commandLineParser.executeWithoutErrorHandling(args)).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('executes a scoped action', async () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + + await commandLineParser.execute(['scoped-action', '--scope', 'foo', '--', '--scoped-foo', 'bar']); + + expect(commandLineParser.selectedAction).toBeDefined(); + expect(commandLineParser.selectedAction!.actionName).toEqual('scoped-action'); + + const action: TestScopedAction = commandLineParser.selectedAction as TestScopedAction; + expect(action.done).toBe(true); + expect(action.scopedValue).toBe('bar'); + }); + + it('prints the action help', () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + const helpText: string = colors.stripColors( + commandLineParser.getAction('scoped-action').renderHelpText() + ); + expect(helpText).toMatchSnapshot(); + }); + + it('prints the scoped action help', async () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + // Execute the parser in order to populate the scoped action to populate the help text. + await commandLineParser.execute(['scoped-action', '--scope', 'foo', '--', '--scoped-foo', 'bar']); + const scopedAction: TestScopedAction & { _getScopedCommandLineParser(): CommandLineParser } = + commandLineParser.getAction('scoped-action') as TestScopedAction & { + _getScopedCommandLineParser(): CommandLineParser; + }; + const scopedCommandLineParser: CommandLineParser = scopedAction._getScopedCommandLineParser(); + const helpText: string = colors.stripColors(scopedCommandLineParser.renderHelpText()); + expect(helpText).toMatchSnapshot(); + }); + + it('prints the unscoped action parameter map', async () => { + const commandLineParser: TestCommandLine = new TestCommandLine(); + // Execute the parser in order to populate the scoped action + await commandLineParser.execute(['scoped-action', '--verbose']); + const scopedAction: TestScopedAction = commandLineParser.getAction('scoped-action') as TestScopedAction; + expect(scopedAction.done).toBe(true); + expect(scopedAction.parameters.length).toBe(2); + const parameterStringMap: Record = scopedAction.getParameterStringMap(); + expect(parameterStringMap).toMatchSnapshot(); + }); + + it('prints the scoped action parameter map', async () => { + let commandLineParser: TestCommandLine = new TestCommandLine(); + // Execute the parser in order to populate the scoped action + await commandLineParser.execute(['scoped-action', '--scope', 'foo']); + let scopedAction: TestScopedAction = commandLineParser.getAction('scoped-action') as TestScopedAction; + expect(scopedAction.done).toBe(true); + expect(scopedAction.parameters.length).toBe(3); + let parameterStringMap: Record = scopedAction.getParameterStringMap(); + expect(parameterStringMap).toMatchSnapshot(); + + commandLineParser = new TestCommandLine(); + // Execute the parser in order to populate the scoped action + await commandLineParser.execute(['scoped-action', '--scope', 'foo', '--', '--scoped-foo', 'bar']); + scopedAction = commandLineParser.getAction('scoped-action') as TestScopedAction; + expect(scopedAction.done).toBe(true); + expect(scopedAction.parameters.length).toBe(3); + parameterStringMap = scopedAction.getParameterStringMap(); + expect(parameterStringMap).toMatchSnapshot(); + }); +}); diff --git a/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap index e11dc7a569d..1250aa3605e 100644 --- a/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap +++ b/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap @@ -12,6 +12,19 @@ Array [ ] `; +exports[`CommandLineRemainder parses an action input with remainder flagged options 1`] = ` +Array [ + "### --title output: ###", + "--title", + "The title", + "### remainder output: ###", + "--", + "--the", + "remaining", + "--args", +] +`; + exports[`CommandLineRemainder prints the action help 1`] = ` "usage: example run [-h] [--title TEXT] ... diff --git a/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap new file mode 100644 index 00000000000..ca696e05d3d --- /dev/null +++ b/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CommandLineParser prints the action help 1`] = ` +"usage: example scoped-action [-h] [--verbose] [--scope SCOPE] ... + +a longer description + +Positional arguments: + \\"...\\" Scoped parameters. Must be prefixed with \\"--\\", ex. \\"-- + --scopedParameter foo --scopedFlag\\". For more information on + available scoped parameters, use \\"-- --help\\". + +Optional arguments: + -h, --help Show this help message and exit. + --verbose A flag parameter. + +Optional scoping arguments: + --scope SCOPE The scope +" +`; + +exports[`CommandLineParser prints the scoped action help 1`] = ` +"usage: example scoped-action --scope foo -- [-h] [--scoped-foo SCOPED] + +a longer description + +Optional arguments: + -h, --help Show this help message and exit. + --scoped-foo SCOPED The scoped argument. + +For more information on available unscoped parameters, use \\"example +scoped-action --help\\" +" +`; + +exports[`CommandLineParser prints the scoped action parameter map 1`] = ` +Object { + "--scope": "\\"foo\\"", + "--scoped-foo": undefined, + "--verbose": "false", +} +`; + +exports[`CommandLineParser prints the scoped action parameter map 2`] = ` +Object { + "--scope": "\\"foo\\"", + "--scoped-foo": "\\"bar\\"", + "--verbose": "false", +} +`; + +exports[`CommandLineParser prints the unscoped action parameter map 1`] = ` +Object { + "--scope": undefined, + "--verbose": "true", +} +`; + +exports[`CommandLineParser throws on missing positional arg divider with unknown positional args 1`] = `"example scoped-action: error: Unrecognized arguments: bar."`; + +exports[`CommandLineParser throws on unknown scoped arg 1`] = ` +"example scoped-action --scope foo --: error: Unrecognized arguments: --scoped-bar baz. +" +`;