Skip to content

Commit

Permalink
feat: POC for allowing flexible command taxonomy (#376)
Browse files Browse the repository at this point in the history
* feat: POC for allowing flexible command taxonomy

* chore: improve implementation

* chore: fix tests

* chore: clean up

* fix: only run command_incomplete if there are matches

* feat: support tax-free aliases and narrow matches based on provided flags

* chore: clean up

* chore: clean up

* chore: add some notes

* perf: add PermutationIndex class

* perf: improve PermutationIndex

* chore: clean up

* perf: use Maps for everything

* chore: clean up

* chore: update unit tests

* chore: update error message

* fix: collateSpacedCmdIDFromArgs

* fix: command array

* fix: commandIDs

* chore: remove _commands cache

* chore: expose methods for getting defined commands

* fix: dont append permutations to this.commands

* chore: code review

* chore: add tests

* Update test/config/util.test.ts

Co-authored-by: Rodrigo Espinosa de los Monteros <1084688+RodEsp@users.noreply.github.com>

* chore: code review

* chore: code review

* chore: update tests

* feat: support topic permutations

Co-authored-by: Rodrigo Espinosa de los Monteros <1084688+RodEsp@users.noreply.github.com>
  • Loading branch information
mdonnalley and RodEsp committed Mar 14, 2022
1 parent 75da28f commit c47c6c6
Show file tree
Hide file tree
Showing 14 changed files with 707 additions and 141 deletions.
323 changes: 235 additions & 88 deletions src/config/config.ts

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,10 @@ export class Plugin implements IPlugin {
this.hooks = mapValues(this.pjson.oclif.hooks || {}, i => Array.isArray(i) ? i : [i])

this.manifest = await this._manifest(Boolean(this.options.ignoreManifest), Boolean(this.options.errorOnManifestCreate))
this.commands = Object.entries(this.manifest.commands)
this.commands = Object
.entries(this.manifest.commands)
.map(([id, c]) => ({...c, pluginAlias: this.alias, pluginType: this.type, load: async () => this.findCommand(id, {must: true})}))
this.commands.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => a.id.localeCompare(b.id))
}

get topics(): Topic[] {
Expand Down
65 changes: 62 additions & 3 deletions src/config/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ export function compact<T>(a: (T | undefined)[]): T[] {
}

export function uniq<T>(arr: T[]): T[] {
return arr.filter((a, i) => {
return !arr.find((b, j) => j > i && b === a)
})
return [...new Set(arr)].sort()
}

function displayWarnings() {
Expand All @@ -61,3 +59,64 @@ export function Debug(...scope: string[]): (..._: any) => void {
if (d.enabled) displayWarnings()
return (...args: any[]) => d(...args)
}

// Adapted from https://github.com/angus-c/just/blob/master/packages/array-permutations/index.js
export function getPermutations(arr: string[]): Array<string[]> {
if (arr.length === 0) return []
if (arr.length === 1) return [arr]

const output = []
const partialPermutations = getPermutations(arr.slice(1))
const first = arr[0]

for (let i = 0, len = partialPermutations.length; i < len; i++) {
const partial = partialPermutations[i]

for (let j = 0, len2 = partial.length; j <= len2; j++) {
const start = partial.slice(0, j)
const end = partial.slice(j)
const merged = start.concat(first, end)

output.push(merged)
}
}

return output
}

export function getCommandIdPermutations(commandId: string): string[] {
return getPermutations(commandId.split(':')).flatMap(c => c.join(':'))
}

/**
* Return an array of ids that represent all the usable combinations that a user could enter.
*
* For example, if the command ids are:
* - foo:bar:baz
* - one:two:three
* Then the usable ids would be:
* - foo
* - foo:bar
* - foo:bar:baz
* - one
* - one:two
* - one:two:three
*
* This allows us to determine which parts of the argv array belong to the command id whenever the topicSeparator is a space.
*
* @param commandIds string[]
* @returns string[]
*/
export function collectUsableIds(commandIds: string[]): string[] {
const usuableIds: string[] = []
for (const id of commandIds) {
const parts = id.split(':')
while (parts.length > 0) {
const name = parts.join(':')
if (name) usuableIds.push(name)
parts.pop()
}
}

return uniq(usuableIds).sort()
}
20 changes: 11 additions & 9 deletions src/help/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,12 @@ import {error} from '../errors'
import CommandHelp from './command'
import RootHelp from './root'
import {compact, sortBy, uniqBy} from '../util'
import {standardizeIDFromArgv} from './util'
import {getHelpFlagAdditions, standardizeIDFromArgv} from './util'
import {HelpFormatter} from './formatter'
import {Plugin} from '../config/plugin'
import {toCached} from '../config/config'
export {CommandHelp} from './command'
export {standardizeIDFromArgv, loadHelpClass} from './util'

const helpFlags = ['--help']

export function getHelpFlagAdditions(config: Interfaces.Config): string[] {
const additionalHelpFlags = config.pjson.oclif.additionalHelpFlags ?? []
return [...new Set([...helpFlags, ...additionalHelpFlags]).values()]
}
export {standardizeIDFromArgv, loadHelpClass, getHelpFlagAdditions} from './util'

function getHelpSubject(args: string[], config: Interfaces.Config): string | undefined {
// for each help flag that starts with '--' create a new flag with same name sans '--'
Expand Down Expand Up @@ -91,6 +84,7 @@ export class Help extends HelpBase {
}

public async showHelp(argv: string[]) {
const originalArgv = argv.slice(1)
argv = argv.filter(arg => !getHelpFlagAdditions(this.config).includes(arg))

if (this.config.topicSeparator !== ':') argv = standardizeIDFromArgv(argv, this.config)
Expand Down Expand Up @@ -123,6 +117,14 @@ export class Help extends HelpBase {
return
}

if (this.config.flexibleTaxonomy) {
const matches = this.config.findMatches(subject, originalArgv)
if (matches.length > 0) {
const result = await this.config.runHook('command_incomplete', {id: subject, argv: originalArgv, matches})
if (result.successes.length > 0) return
}
}

error(`Command ${subject} not found.`)
}

Expand Down
23 changes: 13 additions & 10 deletions src/help/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as ejs from 'ejs'
import {Config as IConfig, HelpOptions} from '../interfaces'
import {Help, HelpBase} from '.'
import ModuleLoader from '../module-loader'
import {collectUsableIds} from '../config/util'

interface HelpBaseDerived {
new(config: IConfig, opts?: Partial<HelpOptions>): HelpBase;
Expand Down Expand Up @@ -38,24 +39,20 @@ export function template(context: any): (t: string) => string {
function collateSpacedCmdIDFromArgs(argv: string[], config: IConfig): string[] {
if (argv.length === 1) return argv

const ids = new Set(config.commandIDs.concat(config.topics.map(t => t.name)))

const ids = collectUsableIds(config.commandIDs)
const findId = (argv: string[]): string | undefined => {
const final: string[] = []
const idPresent = (id: string) => ids.has(id)
const idPresent = (id: string) => ids.includes(id)
const isFlag = (s: string) => s.startsWith('-')
const isArgWithValue = (s: string) => s.includes('=')
const finalizeId = (s?: string) => s ? [...final, s].join(':') : final.join(':')

const hasSubCommandsWithArgs = () => {
const id = finalizeId()
/**
* Get a list of sub commands for the current command id. A command is returned as a subcommand under either
* of these conditions:
* 1. the `id` start with the current command id.
* 2. any of the aliases start with the current command id.
*/
const subCommands = config.commands.filter(c => (c.id).startsWith(id) || c.aliases.some(a => a.startsWith(id)))
if (!id) return false
// Get a list of sub commands for the current command id. A command is returned as a subcommand if the `id` starts with the current command id.
// e.g. `foo:bar` is a subcommand of `foo`
const subCommands = config.commands.filter(c => (c.id).startsWith(id))
return Boolean(subCommands.find(cmd => cmd.strict === false || cmd.args?.length > 0))
}

Expand Down Expand Up @@ -95,3 +92,9 @@ export function standardizeIDFromArgv(argv: string[], config: IConfig): string[]
else if (config.topicSeparator !== ':') argv[0] = toStandardizedId(argv[0], config)
return argv
}

export function getHelpFlagAdditions(config: IConfig): string[] {
const helpFlags = ['--help']
const additionalHelpFlags = config.pjson.oclif.additionalHelpFlags ?? []
return [...new Set([...helpFlags, ...additionalHelpFlags]).values()]
}
4 changes: 4 additions & 0 deletions src/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface Config {
plugins: Plugin[];
binPath?: string;
valid: boolean;
flexibleTaxonomy?: boolean;
topicSeparator: ':' | ' ';
readonly commands: Command.Plugin[];
readonly topics: Topic[];
Expand All @@ -96,10 +97,13 @@ export interface Config {
runCommand<T = unknown>(id: string, argv?: string[]): Promise<T>;
runCommand<T = unknown>(id: string, argv?: string[], cachedCommand?: Command.Plugin): Promise<T>;
runHook<T extends keyof Hooks>(event: T, opts: Hooks[T]['options'], timeout?: number): Promise<Hook.Result<Hooks[T]['return']>>;
getAllCommandIDs(): string[]
getAllCommands(): Command.Plugin[]
findCommand(id: string, opts: { must: true }): Command.Plugin;
findCommand(id: string, opts?: { must: boolean }): Command.Plugin | undefined;
findTopic(id: string, opts: { must: true }): Topic;
findTopic(id: string, opts?: { must: boolean }): Topic | undefined;
findMatches(id: string, argv: string[]): Command.Plugin[];
scopedEnvVar(key: string): string | undefined;
scopedEnvVarKey(key: string): string;
scopedEnvVarTrue(key: string): boolean;
Expand Down
5 changes: 5 additions & 0 deletions src/interfaces/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export interface Hooks {
options: {id: string; argv?: string[]};
return: unknown;
};
'command_incomplete': {
options: {id: string; argv: string[], matches: Command.Plugin[]};
return: unknown;
};
'plugins:preinstall': {
options: {
plugin: { name: string; tag: string; type: 'npm' } | { url: string; type: 'repo' };
Expand All @@ -55,6 +59,7 @@ export namespace Hook {
export type Preupdate = Hook<'preupdate'>
export type Update = Hook<'update'>
export type CommandNotFound = Hook<'command_not_found'>
export type CommandIncomplete = Hook<'command_incomplete'>

export interface Context {
config: Config;
Expand Down
2 changes: 2 additions & 0 deletions src/interfaces/pjson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export namespace PJSON {
schema?: number;
description?: string;
topicSeparator?: ':' | ' ';
flexibleTaxonomy?: boolean;
hooks?: { [name: string]: (string | string[]) };
commands?: string;
default?: string;
Expand Down Expand Up @@ -77,6 +78,7 @@ export namespace PJSON {
npmRegistry?: string;
scope?: string;
dirname?: string;
flexibleTaxonomy?: boolean;
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export async function run(argv = process.argv.slice(2), options?: Interfaces.Loa
// find & run command
const cmd = config.findCommand(id)
if (!cmd) {
const topic = config.findTopic(id)
const topic = config.flexibleTaxonomy ? null : config.findTopic(id)
if (topic) return config.runCommand('help', [id])
if (config.pjson.oclif.default) {
id = config.pjson.oclif.default
Expand Down
Loading

0 comments on commit c47c6c6

Please sign in to comment.