Skip to content

Commit

Permalink
feat: Command-Line Completions
Browse files Browse the repository at this point in the history
  • Loading branch information
imhoffd committed Apr 17, 2019
1 parent 79480b9 commit 9f66512
Show file tree
Hide file tree
Showing 6 changed files with 300 additions and 0 deletions.
170 changes: 170 additions & 0 deletions packages/@ionic/cli-framework/src/lib/__tests__/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { Command, CommandMap, CommandMapDefault, Namespace, NamespaceMap } from '../command';
import { CommandMetadata, NamespaceMetadata } from '../../definitions';

import { getCompletionWords } from '../completion';

class MyNamespace extends Namespace {
async getMetadata(): Promise<CommandMetadata> {
return {
name: 'my',
summary: '',
};
}

async getNamespaces(): Promise<NamespaceMap> {
return new NamespaceMap([
['foo', async () => new FooNamespace(this)],
['defns', async () => new NamespaceWithDefault(this)],
['f', 'foo'],
]);
}
}

class NamespaceWithDefault extends Namespace {
async getMetadata(): Promise<NamespaceMetadata> {
return {
name: 'defns',
summary: '',
};
}

async getCommands(): Promise<CommandMap> {
return new CommandMap([
[CommandMapDefault, async () => new DefaultCommand(this)],
]);
}
}

class FooNamespace extends Namespace {
async getMetadata(): Promise<NamespaceMetadata> {
return {
name: 'foo',
summary: '',
};
}

async getCommands(): Promise<CommandMap> {
return new CommandMap([
['bar', async () => new BarCommand(this)],
['baz', async () => new BazCommand(this)],
['b', 'bar'],
]);
}
}

class EmptyNamespace extends Namespace {
async getMetadata(): Promise<NamespaceMetadata> {
return {
name: 'empty',
summary: ''
};
}
}

class DefaultCommand extends Command {
async getMetadata(): Promise<CommandMetadata> {
return {
name: 'def',
summary: '',
options: [
{
name: 'str-opt',
summary: '',
},
{
name: 'bool-opt',
summary: '',
type: Boolean,
default: true,
},
],
};
}

async run() {}
}

class BarCommand extends Command {
async getMetadata(): Promise<CommandMetadata> {
return {
name: 'bar',
summary: '',
options: [
{
name: 'str-opt',
summary: '',
},
{
name: 'bool-opt',
summary: '',
type: Boolean,
default: true,
},
],
};
}

async run() {}
}

class BazCommand extends Command {
async getMetadata(): Promise<CommandMetadata> {
return {
name: 'baz',
summary: '',
};
}

async run() {}
}

describe('@ionic/cli-framework', () => {

describe('lib/completion', () => {

describe('getCompletionWords', () => {

it('should have no words for empty namespace', async () => {
const ns = new EmptyNamespace();
const words = await getCompletionWords(ns, []);
expect(words).toEqual([]);
});

it('should return command words for a namespace', async () => {
const ns = new FooNamespace();
const words = await getCompletionWords(ns, []);
expect(words).toEqual(['bar', 'baz']);
});

it('should return command and namespace words for a namespace', async () => {
const ns = new MyNamespace();
const words = await getCompletionWords(ns, []);
expect(words).toEqual(['defns', 'foo']);
});

it('should return options from a default namespace', async () => {
const ns = new MyNamespace();
debugger;
const words = await getCompletionWords(ns, ['defns']);
expect(words).toEqual(['--no-bool-opt', '--str-opt']);
});

it('should return options from a command', async () => {
const ns = new MyNamespace();
debugger;
const words = await getCompletionWords(ns, ['foo', 'bar']);
expect(words).toEqual(['--no-bool-opt', '--str-opt']);
});

it('should return unique options from a command', async () => {
const ns = new MyNamespace();
debugger;
const words = await getCompletionWords(ns, ['foo', 'bar', '--str-opt']);
expect(words).toEqual(['--no-bool-opt']);
});

})

});

});
68 changes: 68 additions & 0 deletions packages/@ionic/cli-framework/src/lib/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as lodash from 'lodash';

import { CommandMetadata, CommandMetadataInput, CommandMetadataOption, ICommand, INamespace } from '../definitions';
import { isCommand } from '../guards';

import { NO_COLORS } from './colors';
import { formatOptionName } from './options';

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[]> {
const { obj } = await ns.locate(argv, { useAliases: false });

if (isCommand(obj)) {
const metadata = await obj.getMetadata();
const options = metadata.options ? metadata.options : [];

if (options.length === 0) {
return [];
}

const optionNames = options
.map(option => formatOptionName(option, { showAliases: false, showValueSpec: false, colors: NO_COLORS }))
.filter(name => !argv.includes(name));

const aliasNames = lodash.flatten(options.map(option => option.aliases ? option.aliases : []))
.map(alias => `-${alias}`);

return [...optionNames, ...aliasNames].sort();
}

return [
...(await obj.getCommands()).keysWithoutAliases(),
...(await obj.getNamespaces()).keysWithoutAliases(),
].sort();
}

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> {
readonly namespace: N;
}

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> {
protected readonly namespace: N;

constructor({ namespace }: CompletionFormatterDeps<C, N, M, I, O>) {
this.namespace = namespace;
}

abstract format(): Promise<string>;
}

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> {
async format(): Promise<string> {
const { name } = await this.namespace.getMetadata();

return `
###-begin-${name}-completion-###
if type compdef &>/dev/null; then
__${name}() {
compadd -- $(${name} completion -- "$\{words[@]}" 2>/dev/null)
}
compdef __${name} ${name}
fi
###-end-${name}-completion-###
`;
}
}
1 change: 1 addition & 0 deletions packages/@ionic/cli-framework/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Logger } from './logger';

export * from './colors';
export * from './command';
export * from './completion';
export * from './config';
export * from './executor';
export * from './help';
Expand Down
54 changes: 54 additions & 0 deletions packages/ionic/src/commands/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { MetadataGroup, ZshCompletionFormatter, getCompletionWords } from '@ionic/cli-framework';
import { TERMINAL_INFO } from '@ionic/utils-terminal';
import * as path from 'path';

import { CommandLineInputs, CommandLineOptions, CommandMetadata } from '../definitions';
import { strong } from '../lib/color';
import { Command } from '../lib/command';
import { FatalException } from '../lib/errors';

export class CompletionCommand extends Command {
async getMetadata(): Promise<CommandMetadata> {
return {
name: 'completion',
type: 'global',
summary: 'Enables tab-completion for Ionic CLI commands.',
description: `
This command is experimental and only works for Z shell (zsh) and non-Windows platforms.
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.
`,
groups: [MetadataGroup.EXPERIMENTAL],
exampleCommands: [
'',
'>> ~/.zshrc',
],
};
}

async run(inputs: CommandLineInputs, options: CommandLineOptions): Promise<void> {
if (TERMINAL_INFO.windows) {
throw new FatalException('Completion is not supported on Windows shells.');
}

if (path.basename(TERMINAL_INFO.shell) !== 'zsh') {
throw new FatalException('Completion is currently only available for Z Shell (zsh).');
}

const words = options['--'];

if (!words || words.length === 0) {
const namespace = this.namespace.root;
const formatter = new ZshCompletionFormatter({ namespace });

process.stdout.write(await formatter.format());

return;
}

const ns = this.namespace.root;
const outputWords = await getCompletionWords(ns, words.slice(1));

process.stdout.write(outputWords.join(' '));
}
}
1 change: 1 addition & 0 deletions packages/ionic/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class IonicNamespace extends Namespace {
async getCommands(): Promise<CommandMap> {
return new CommandMap([
['build', async () => { const { BuildCommand } = await import('./build'); return new BuildCommand(this); }],
['completion', async () => { const { CompletionCommand } = await import('./completion'); return new CompletionCommand(this); }],
['docs', async () => { const { DocsCommand } = await import('./docs'); return new DocsCommand(this); }],
['generate', async () => { const { GenerateCommand } = await import('./generate'); return new GenerateCommand(this); }],
['help', async () => { const { HelpCommand } = await import('./help'); return new HelpCommand(this); }],
Expand Down
6 changes: 6 additions & 0 deletions packages/ionic/src/lib/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ export abstract class Command extends BaseCommand<ICommand, INamespace, CommandM
const metadata = await this.getMetadata();

if (metadata.name === 'login' || metadata.name === 'logout') {
// This is a hack to wait until the selected commands complete before
// sending telemetry data. These commands update `this.env` in some
// way, which is used in the `Telemetry` instance.
await runPromise;
} else if (metadata.name === 'completion') {
// Ignore telemetry for these commands.
return;
} else if (metadata.name === 'help') {
cmdInputs = inputs;
} else {
Expand Down

0 comments on commit 9f66512

Please sign in to comment.