Skip to content

feat(@angular/cli): add support for auto completion #22967

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 13, 2022
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
Expand Up @@ -7,7 +7,10 @@
*/

import { Architect, Target } from '@angular-devkit/architect';
import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node';
import {
NodeModulesBuilderInfo,
WorkspaceNodeModulesArchitectHost,
} from '@angular-devkit/architect/node';
import { json } from '@angular-devkit/core';
import { spawnSync } from 'child_process';
import { existsSync } from 'fs';
Expand Down Expand Up @@ -100,9 +103,15 @@ export abstract class ArchitectBaseCommandModule<T>

protected async getArchitectTargetOptions(target: Target): Promise<Option[]> {
const architectHost = this.getArchitectHost();
const builderConf = await architectHost.getBuilderNameForTarget(target);
let builderConf: string;

try {
builderConf = await architectHost.getBuilderNameForTarget(target);
} catch {
return [];
}

let builderDesc;
let builderDesc: NodeModulesBuilderInfo;
try {
builderDesc = await architectHost.resolveBuilder(builderConf);
} catch (e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ export abstract class ArchitectCommandModule
abstract readonly multiTarget: boolean;

async builder(argv: Argv): Promise<Argv<ArchitectCommandArgs>> {
const project = this.getArchitectProject();
const { jsonHelp, getYargsCompletions, help } = this.context.args.options;

const localYargs: Argv<ArchitectCommandArgs> = argv
.positional('project', {
describe: 'The name of the project to build. Can be an application or a library.',
type: 'string',
// Hide choices from JSON help so that we don't display them in AIO.
choices: jsonHelp ? undefined : this.getProjectChoices(),
})
.option('configuration', {
describe:
Expand All @@ -42,10 +47,15 @@ export abstract class ArchitectCommandModule
`For more information, see https://angular.io/guide/workspace-config#alternate-build-configurations.`,
alias: 'c',
type: 'string',
// Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid.
// Also, hide choices from JSON help so that we don't display them in AIO.
choices:
(getYargsCompletions || help) && !jsonHelp && project
? this.getConfigurationChoices(project)
: undefined,
})
.strict();

const project = this.getArchitectProject();
if (!project) {
return localYargs;
}
Expand Down Expand Up @@ -92,11 +102,7 @@ export abstract class ArchitectCommandModule
const [, projectName] = this.context.args.positional;

if (projectName) {
if (!workspace.projects.has(projectName)) {
throw new CommandModuleError(`Project '${projectName}' does not exist.`);
}

return projectName;
return workspace.projects.has(projectName) ? projectName : undefined;
}

const target = this.getArchitectTarget();
Expand Down Expand Up @@ -136,4 +142,24 @@ export abstract class ArchitectCommandModule

return undefined;
}

/** @returns a sorted list of project names to be used for auto completion. */
private getProjectChoices(): string[] | undefined {
const { workspace } = this.context;

return workspace ? [...workspace.projects.keys()].sort() : undefined;
}

/** @returns a sorted list of configuration names to be used for auto completion. */
private getConfigurationChoices(project: string): string[] | undefined {
const projectDefinition = this.context.workspace?.projects.get(project);
if (!projectDefinition) {
return undefined;
}

const target = this.getArchitectTarget();
const configurations = projectDefinition.targets.get(target)?.configurations;

return configurations ? Object.keys(configurations).sort() : undefined;
}
}
1 change: 1 addition & 0 deletions packages/angular/cli/src/command-builder/command-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface CommandContext {
options: {
help: boolean;
jsonHelp: boolean;
getYargsCompletions: boolean;
} & Record<string, unknown>;
};
}
Expand Down
25 changes: 21 additions & 4 deletions packages/angular/cli/src/command-builder/command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { AddCommandModule } from '../commands/add/cli';
import { AnalyticsCommandModule } from '../commands/analytics/cli';
import { BuildCommandModule } from '../commands/build/cli';
import { CacheCommandModule } from '../commands/cache/cli';
import { CompletionCommandModule } from '../commands/completion/cli';
import { ConfigCommandModule } from '../commands/config/cli';
import { DeployCommandModule } from '../commands/deploy/cli';
import { DocCommandModule } from '../commands/doc/cli';
Expand Down Expand Up @@ -54,18 +55,26 @@ const COMMANDS = [
UpdateCommandModule,
RunCommandModule,
CacheCommandModule,
CompletionCommandModule,
].sort(); // Will be sorted by class name.

const yargsParser = Parser as unknown as typeof Parser.default;

export async function runCommand(args: string[], logger: logging.Logger): Promise<number> {
const {
$0,
_: positional,
_,
help = false,
jsonHelp = false,
getYargsCompletions = false,
...rest
} = yargsParser(args, { boolean: ['help', 'json-help'], alias: { 'collection': 'c' } });
} = yargsParser(args, {
boolean: ['help', 'json-help', 'get-yargs-completions'],
alias: { 'collection': 'c' },
});

// When `getYargsCompletions` is true the scriptName 'ng' at index 0 is not removed.
const positional = getYargsCompletions ? _.slice(1) : _;

let workspace: AngularWorkspace | undefined;
let globalConfiguration: AngularWorkspace | undefined;
Expand Down Expand Up @@ -93,6 +102,7 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis
options: {
help,
jsonHelp,
getYargsCompletions,
...rest,
},
},
Expand All @@ -111,9 +121,16 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis
localYargs = addCommandModuleToYargs(localYargs, CommandModule, context);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const usageInstance = (localYargs as any).getInternalMethods().getUsageInstance();
if (jsonHelp) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(localYargs as any).getInternalMethods().getUsageInstance().help = () => jsonHelpUsage();
usageInstance.help = () => jsonHelpUsage();
}

if (getYargsCompletions) {
// When in auto completion mode avoid printing description as it causes a slugish
// experience when there are a large set of options.
usageInstance.getDescriptions = () => ({});
}

await localYargs
Expand Down
25 changes: 25 additions & 0 deletions packages/angular/cli/src/commands/completion/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import { join } from 'path';
import yargs, { Argv } from 'yargs';
import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module';

export class CompletionCommandModule extends CommandModule implements CommandModuleImplementation {
command = 'completion';
describe = 'Generate a bash and zsh real-time type-ahead autocompletion script.';
longDescriptionPath = join(__dirname, 'long-description.md');

builder(localYargs: Argv): Argv {
return localYargs;
}

run(): void {
yargs.showCompletionScript();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
To enable bash and zsh real-time type-ahead autocompletion, copy and paste the generated script to your `.bashrc`, `.bash_profile`, `.zshrc` or `.zsh_profile`.
31 changes: 30 additions & 1 deletion packages/angular/cli/src/commands/run/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { join } from 'path';
import { Argv } from 'yargs';
import { ArchitectBaseCommandModule } from '../../command-builder/architect-base-command-module';
import {
CommandModule,
CommandModuleError,
CommandModuleImplementation,
CommandScope,
Expand All @@ -35,11 +34,16 @@ export class RunCommandModule
longDescriptionPath = join(__dirname, 'long-description.md');

async builder(argv: Argv): Promise<Argv<RunCommandArgs>> {
const { jsonHelp, getYargsCompletions, help } = this.context.args.options;

const localYargs: Argv<RunCommandArgs> = argv
.positional('target', {
describe: 'The Architect target to run.',
type: 'string',
demandOption: true,
// Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid.
// Also, hide choices from JSON help so that we don't display them in AIO.
choices: (getYargsCompletions || help) && !jsonHelp ? this.getTargetChoices() : undefined,
})
.strict();

Expand Down Expand Up @@ -78,4 +82,29 @@ export class RunCommandModule
configuration,
};
}

/** @returns a sorted list of target specifiers to be used for auto completion. */
private getTargetChoices(): string[] | undefined {
if (!this.context.workspace) {
return;
}

const targets = [];
for (const [projectName, project] of this.context.workspace.projects) {
for (const [targetName, target] of project.targets) {
const currentTarget = `${projectName}:${targetName}`;
targets.push(currentTarget);

if (!target.configurations) {
continue;
}

for (const configName of Object.keys(target.configurations)) {
targets.push(`${currentTarget}:${configName}`);
}
}
}

return targets.sort();
}
}
2 changes: 1 addition & 1 deletion tests/legacy-cli/e2e/tests/misc/browsers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import express from 'express';
import * as path from 'path';
import { copyProjectAsset } from '../../utils/assets';
import { appendToFile, replaceInFile } from '../../utils/fs';
import { replaceInFile } from '../../utils/fs';
import { ng } from '../../utils/process';

export default async function () {
Expand Down
51 changes: 51 additions & 0 deletions tests/legacy-cli/e2e/tests/misc/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { execAndWaitForOutputToMatch } from '../../utils/process';

export default async function () {
// ng build
await execAndWaitForOutputToMatch('ng', ['--get-yargs-completions', 'b', ''], /test-project/);
await execAndWaitForOutputToMatch('ng', ['--get-yargs-completions', 'build', ''], /test-project/);
await execAndWaitForOutputToMatch('ng', ['--get-yargs-completions', 'build', '--a'], /--aot/);
await execAndWaitForOutputToMatch(
'ng',
['--get-yargs-completions', 'build', '--configuration'],
/production/,
);
await execAndWaitForOutputToMatch(
'ng',
['--get-yargs-completions', 'b', '--configuration'],
/production/,
);

// ng run
await execAndWaitForOutputToMatch(
'ng',
['--get-yargs-completions', 'run', ''],
/test-project\:build\:development/,
);
await execAndWaitForOutputToMatch(
'ng',
['--get-yargs-completions', 'run', ''],
/test-project\:build/,
);
await execAndWaitForOutputToMatch(
'ng',
['--get-yargs-completions', 'run', ''],
/test-project\:test/,
);
await execAndWaitForOutputToMatch(
'ng',
['--get-yargs-completions', 'run', 'test-project:build'],
/test-project\:build\:development/,
);
await execAndWaitForOutputToMatch(
'ng',
['--get-yargs-completions', 'run', 'test-project:'],
/test-project\:test/,
);
await execAndWaitForOutputToMatch(
'ng',
['--get-yargs-completions', 'run', 'test-project:build'],
// does not include 'test-project:serve'
/^((?!:serve).)*$/,
);
}