Skip to content

Commit 9f66512

Browse files
committed
feat: Command-Line Completions
1 parent 79480b9 commit 9f66512

File tree

6 files changed

+300
-0
lines changed

6 files changed

+300
-0
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { Command, CommandMap, CommandMapDefault, Namespace, NamespaceMap } from '../command';
2+
import { CommandMetadata, NamespaceMetadata } from '../../definitions';
3+
4+
import { getCompletionWords } from '../completion';
5+
6+
class MyNamespace extends Namespace {
7+
async getMetadata(): Promise<CommandMetadata> {
8+
return {
9+
name: 'my',
10+
summary: '',
11+
};
12+
}
13+
14+
async getNamespaces(): Promise<NamespaceMap> {
15+
return new NamespaceMap([
16+
['foo', async () => new FooNamespace(this)],
17+
['defns', async () => new NamespaceWithDefault(this)],
18+
['f', 'foo'],
19+
]);
20+
}
21+
}
22+
23+
class NamespaceWithDefault extends Namespace {
24+
async getMetadata(): Promise<NamespaceMetadata> {
25+
return {
26+
name: 'defns',
27+
summary: '',
28+
};
29+
}
30+
31+
async getCommands(): Promise<CommandMap> {
32+
return new CommandMap([
33+
[CommandMapDefault, async () => new DefaultCommand(this)],
34+
]);
35+
}
36+
}
37+
38+
class FooNamespace extends Namespace {
39+
async getMetadata(): Promise<NamespaceMetadata> {
40+
return {
41+
name: 'foo',
42+
summary: '',
43+
};
44+
}
45+
46+
async getCommands(): Promise<CommandMap> {
47+
return new CommandMap([
48+
['bar', async () => new BarCommand(this)],
49+
['baz', async () => new BazCommand(this)],
50+
['b', 'bar'],
51+
]);
52+
}
53+
}
54+
55+
class EmptyNamespace extends Namespace {
56+
async getMetadata(): Promise<NamespaceMetadata> {
57+
return {
58+
name: 'empty',
59+
summary: ''
60+
};
61+
}
62+
}
63+
64+
class DefaultCommand extends Command {
65+
async getMetadata(): Promise<CommandMetadata> {
66+
return {
67+
name: 'def',
68+
summary: '',
69+
options: [
70+
{
71+
name: 'str-opt',
72+
summary: '',
73+
},
74+
{
75+
name: 'bool-opt',
76+
summary: '',
77+
type: Boolean,
78+
default: true,
79+
},
80+
],
81+
};
82+
}
83+
84+
async run() {}
85+
}
86+
87+
class BarCommand extends Command {
88+
async getMetadata(): Promise<CommandMetadata> {
89+
return {
90+
name: 'bar',
91+
summary: '',
92+
options: [
93+
{
94+
name: 'str-opt',
95+
summary: '',
96+
},
97+
{
98+
name: 'bool-opt',
99+
summary: '',
100+
type: Boolean,
101+
default: true,
102+
},
103+
],
104+
};
105+
}
106+
107+
async run() {}
108+
}
109+
110+
class BazCommand extends Command {
111+
async getMetadata(): Promise<CommandMetadata> {
112+
return {
113+
name: 'baz',
114+
summary: '',
115+
};
116+
}
117+
118+
async run() {}
119+
}
120+
121+
describe('@ionic/cli-framework', () => {
122+
123+
describe('lib/completion', () => {
124+
125+
describe('getCompletionWords', () => {
126+
127+
it('should have no words for empty namespace', async () => {
128+
const ns = new EmptyNamespace();
129+
const words = await getCompletionWords(ns, []);
130+
expect(words).toEqual([]);
131+
});
132+
133+
it('should return command words for a namespace', async () => {
134+
const ns = new FooNamespace();
135+
const words = await getCompletionWords(ns, []);
136+
expect(words).toEqual(['bar', 'baz']);
137+
});
138+
139+
it('should return command and namespace words for a namespace', async () => {
140+
const ns = new MyNamespace();
141+
const words = await getCompletionWords(ns, []);
142+
expect(words).toEqual(['defns', 'foo']);
143+
});
144+
145+
it('should return options from a default namespace', async () => {
146+
const ns = new MyNamespace();
147+
debugger;
148+
const words = await getCompletionWords(ns, ['defns']);
149+
expect(words).toEqual(['--no-bool-opt', '--str-opt']);
150+
});
151+
152+
it('should return options from a command', async () => {
153+
const ns = new MyNamespace();
154+
debugger;
155+
const words = await getCompletionWords(ns, ['foo', 'bar']);
156+
expect(words).toEqual(['--no-bool-opt', '--str-opt']);
157+
});
158+
159+
it('should return unique options from a command', async () => {
160+
const ns = new MyNamespace();
161+
debugger;
162+
const words = await getCompletionWords(ns, ['foo', 'bar', '--str-opt']);
163+
expect(words).toEqual(['--no-bool-opt']);
164+
});
165+
166+
})
167+
168+
});
169+
170+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as lodash from 'lodash';
2+
3+
import { CommandMetadata, CommandMetadataInput, CommandMetadataOption, ICommand, INamespace } from '../definitions';
4+
import { isCommand } from '../guards';
5+
6+
import { NO_COLORS } from './colors';
7+
import { formatOptionName } from './options';
8+
9+
export async function getCompletionWords<C extends ICommand<C, N, M, I, O>, N extends INamespace<C, N, M, I, O>, M extends CommandMetadata<I, O>, I extends CommandMetadataInput, O extends CommandMetadataOption>(ns: N, argv: ReadonlyArray<string>): Promise<string[]> {
10+
const { obj } = await ns.locate(argv, { useAliases: false });
11+
12+
if (isCommand(obj)) {
13+
const metadata = await obj.getMetadata();
14+
const options = metadata.options ? metadata.options : [];
15+
16+
if (options.length === 0) {
17+
return [];
18+
}
19+
20+
const optionNames = options
21+
.map(option => formatOptionName(option, { showAliases: false, showValueSpec: false, colors: NO_COLORS }))
22+
.filter(name => !argv.includes(name));
23+
24+
const aliasNames = lodash.flatten(options.map(option => option.aliases ? option.aliases : []))
25+
.map(alias => `-${alias}`);
26+
27+
return [...optionNames, ...aliasNames].sort();
28+
}
29+
30+
return [
31+
...(await obj.getCommands()).keysWithoutAliases(),
32+
...(await obj.getNamespaces()).keysWithoutAliases(),
33+
].sort();
34+
}
35+
36+
export interface CompletionFormatterDeps<C extends ICommand<C, N, M, I, O>, N extends INamespace<C, N, M, I, O>, M extends CommandMetadata<I, O>, I extends CommandMetadataInput, O extends CommandMetadataOption> {
37+
readonly namespace: N;
38+
}
39+
40+
export abstract class CompletionFormatter<C extends ICommand<C, N, M, I, O>, N extends INamespace<C, N, M, I, O>, M extends CommandMetadata<I, O>, I extends CommandMetadataInput, O extends CommandMetadataOption> {
41+
protected readonly namespace: N;
42+
43+
constructor({ namespace }: CompletionFormatterDeps<C, N, M, I, O>) {
44+
this.namespace = namespace;
45+
}
46+
47+
abstract format(): Promise<string>;
48+
}
49+
50+
export class ZshCompletionFormatter<C extends ICommand<C, N, M, I, O>, N extends INamespace<C, N, M, I, O>, M extends CommandMetadata<I, O>, I extends CommandMetadataInput, O extends CommandMetadataOption> extends CompletionFormatter<C, N, M, I, O> {
51+
async format(): Promise<string> {
52+
const { name } = await this.namespace.getMetadata();
53+
54+
return `
55+
###-begin-${name}-completion-###
56+
57+
if type compdef &>/dev/null; then
58+
__${name}() {
59+
compadd -- $(${name} completion -- "$\{words[@]}" 2>/dev/null)
60+
}
61+
62+
compdef __${name} ${name}
63+
fi
64+
65+
###-end-${name}-completion-###
66+
`;
67+
}
68+
}

packages/@ionic/cli-framework/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Logger } from './logger';
22

33
export * from './colors';
44
export * from './command';
5+
export * from './completion';
56
export * from './config';
67
export * from './executor';
78
export * from './help';
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { MetadataGroup, ZshCompletionFormatter, getCompletionWords } from '@ionic/cli-framework';
2+
import { TERMINAL_INFO } from '@ionic/utils-terminal';
3+
import * as path from 'path';
4+
5+
import { CommandLineInputs, CommandLineOptions, CommandMetadata } from '../definitions';
6+
import { strong } from '../lib/color';
7+
import { Command } from '../lib/command';
8+
import { FatalException } from '../lib/errors';
9+
10+
export class CompletionCommand extends Command {
11+
async getMetadata(): Promise<CommandMetadata> {
12+
return {
13+
name: 'completion',
14+
type: 'global',
15+
summary: 'Enables tab-completion for Ionic CLI commands.',
16+
description: `
17+
This command is experimental and only works for Z shell (zsh) and non-Windows platforms.
18+
19+
To enable completions for the Ionic CLI, you can add the completion code that this command prints to your ${strong('~/.zshrc')} (or any other file loaded with your shell). See the examples.
20+
`,
21+
groups: [MetadataGroup.EXPERIMENTAL],
22+
exampleCommands: [
23+
'',
24+
'>> ~/.zshrc',
25+
],
26+
};
27+
}
28+
29+
async run(inputs: CommandLineInputs, options: CommandLineOptions): Promise<void> {
30+
if (TERMINAL_INFO.windows) {
31+
throw new FatalException('Completion is not supported on Windows shells.');
32+
}
33+
34+
if (path.basename(TERMINAL_INFO.shell) !== 'zsh') {
35+
throw new FatalException('Completion is currently only available for Z Shell (zsh).');
36+
}
37+
38+
const words = options['--'];
39+
40+
if (!words || words.length === 0) {
41+
const namespace = this.namespace.root;
42+
const formatter = new ZshCompletionFormatter({ namespace });
43+
44+
process.stdout.write(await formatter.format());
45+
46+
return;
47+
}
48+
49+
const ns = this.namespace.root;
50+
const outputWords = await getCompletionWords(ns, words.slice(1));
51+
52+
process.stdout.write(outputWords.join(' '));
53+
}
54+
}

packages/ionic/src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export class IonicNamespace extends Namespace {
6161
async getCommands(): Promise<CommandMap> {
6262
return new CommandMap([
6363
['build', async () => { const { BuildCommand } = await import('./build'); return new BuildCommand(this); }],
64+
['completion', async () => { const { CompletionCommand } = await import('./completion'); return new CompletionCommand(this); }],
6465
['docs', async () => { const { DocsCommand } = await import('./docs'); return new DocsCommand(this); }],
6566
['generate', async () => { const { GenerateCommand } = await import('./generate'); return new GenerateCommand(this); }],
6667
['help', async () => { const { HelpCommand } = await import('./help'); return new HelpCommand(this); }],

packages/ionic/src/lib/command.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,13 @@ export abstract class Command extends BaseCommand<ICommand, INamespace, CommandM
7070
const metadata = await this.getMetadata();
7171

7272
if (metadata.name === 'login' || metadata.name === 'logout') {
73+
// This is a hack to wait until the selected commands complete before
74+
// sending telemetry data. These commands update `this.env` in some
75+
// way, which is used in the `Telemetry` instance.
7376
await runPromise;
77+
} else if (metadata.name === 'completion') {
78+
// Ignore telemetry for these commands.
79+
return;
7480
} else if (metadata.name === 'help') {
7581
cmdInputs = inputs;
7682
} else {

0 commit comments

Comments
 (0)