Repository navigation
Add local Bash, Zsh, and Fish completion #52
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
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
44f968c
feat: add local shell completion for bash zsh and fish
purpshell 12566b8
test: check shell syntax only when installed
purpshell 8b49dd0
fix: complete negated boolean flags
purpshell 43abaf6
fix: complete Bash equals-form flag choices
purpshell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import {Args, Command} from '@oclif/core' | ||
|
|
||
| import {completionScript, type CompletionShell} from '../services/completion.js' | ||
|
|
||
| export default class Completion extends Command { | ||
| static override args = { | ||
| shell: Args.string({ | ||
| description: 'Shell to generate completion for.', | ||
| options: ['bash', 'zsh', 'fish'], | ||
| required: true, | ||
| }), | ||
| } | ||
|
|
||
| static override description = | ||
| 'Print a Bash, Zsh, or Fish completion script using local command metadata.' | ||
|
|
||
| static override examples = [ | ||
| '<%= config.bin %> completion bash', | ||
| '<%= config.bin %> completion zsh', | ||
| '<%= config.bin %> completion fish', | ||
| ] | ||
|
|
||
| async run(): Promise<void> { | ||
| const {args} = await this.parse(Completion) | ||
| process.stdout.write(`${completionScript(args.shell as CompletionShell)}\n`) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import {Args, Command, Flags} from '@oclif/core' | ||
|
|
||
| import {suggestCompletions} from '../../services/completion.js' | ||
|
|
||
| /** Called by generated shell scripts. No auth, keyring, project, or HTTP path. */ | ||
| export default class Suggest extends Command { | ||
| static override args = { | ||
| words: Args.string({multiple: true}), | ||
| } | ||
|
|
||
| static override flags = { | ||
| current: Flags.string({default: ''}), | ||
| } | ||
|
|
||
| static override hidden = true | ||
|
|
||
| async run(): Promise<void> { | ||
| const {args, flags} = await this.parse(Suggest) | ||
| const suggestions = suggestCompletions( | ||
| this.config.commands, | ||
| args.words ?? [], | ||
| flags.current, | ||
| ) | ||
| if (suggestions.length > 0) { | ||
| process.stdout.write(`${suggestions.join('\n')}\n`) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| /** Completion uses only local command metadata. It never resolves a profile or calls an API. */ | ||
| export interface CompletionFlag { | ||
| readonly allowNo?: boolean | ||
| readonly char?: string | ||
| readonly hidden?: boolean | ||
| readonly options?: readonly string[] | ||
| readonly type?: string | ||
| } | ||
|
|
||
| export interface CompletionCommand { | ||
| readonly aliases?: readonly string[] | ||
| readonly flags?: Readonly<Record<string, CompletionFlag>> | ||
| readonly hidden?: boolean | ||
| readonly id: string | ||
| } | ||
|
|
||
| const SAFE_WORD = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ | ||
|
|
||
| export function suggestCompletions( | ||
| commands: readonly CompletionCommand[], | ||
| priorWords: readonly string[], | ||
| current = '', | ||
| ): string[] { | ||
| const visible = commands.filter((command) => !command.hidden) | ||
| const paths = visible.flatMap((command) => | ||
| [command.id, ...(command.aliases ?? [])] | ||
| .map((id) => id.split(':')) | ||
| .filter((parts) => parts.every((part) => SAFE_WORD.test(part))), | ||
| ) | ||
| let path: string[] = [] | ||
| let pending: CompletionFlag | undefined | ||
| let positional = false | ||
| let stopped = false | ||
|
|
||
| for (const word of priorWords) { | ||
| if (pending !== undefined) { | ||
| pending = undefined | ||
| continue | ||
| } | ||
| if (word === '--') { | ||
| stopped = true | ||
| continue | ||
| } | ||
| if (stopped) continue | ||
| if (word.startsWith('-')) { | ||
| const [name, value] = word.split('=', 2) | ||
| const flag = availableFlags(visible, path).find( | ||
| ([candidate]) => candidate === name, | ||
| )?.[1] | ||
| if (flag?.type === 'option' && value === undefined) pending = flag | ||
| continue | ||
| } | ||
| if ( | ||
| !positional && | ||
| paths.some( | ||
| (parts) => | ||
| parts.length > path.length && | ||
| path.every((part, index) => parts[index] === part) && | ||
| parts[path.length] === word, | ||
| ) | ||
| ) { | ||
| path = [...path, word] | ||
| continue | ||
| } | ||
| // Positional values are never a source of suggestions. Flags can still | ||
| // follow them. | ||
| if (!paths.some((parts) => parts.join(':') === path.join(':'))) return [] | ||
| positional = true | ||
| } | ||
|
|
||
| if (stopped) return [] | ||
| if (pending !== undefined) { | ||
| return safeCandidates(pending.options ?? [], current) | ||
| } | ||
|
|
||
| const equals = current.indexOf('=') | ||
| if (current.startsWith('-') && equals > 0) { | ||
| const flag = availableFlags(visible, path).find( | ||
| ([candidate]) => candidate === current.slice(0, equals), | ||
| )?.[1] | ||
| return flag?.type === 'option' | ||
| ? safeCandidates(flag.options ?? [], current.slice(equals + 1)).map( | ||
| (option) => `${current.slice(0, equals + 1)}${option}`, | ||
| ) | ||
| : [] | ||
| } | ||
| if (current.startsWith('-')) { | ||
| return safeCandidates( | ||
| [...availableFlags(visible, path).map(([name]) => name), '--help', '-h'], | ||
| current, | ||
| ) | ||
| } | ||
|
|
||
| if (positional) return [] | ||
|
|
||
| return safeCandidates( | ||
| paths | ||
| .filter( | ||
| (parts) => | ||
| parts.length > path.length && | ||
| path.every((part, index) => parts[index] === part), | ||
| ) | ||
| .map((parts) => parts[path.length] ?? ''), | ||
| current, | ||
| ) | ||
| } | ||
|
|
||
| function availableFlags( | ||
| commands: readonly CompletionCommand[], | ||
| path: readonly string[], | ||
| ): [string, CompletionFlag][] { | ||
| const command = commands.find((item) => item.id === path.join(':')) | ||
| const root = commands.find((item) => item.id === 'version') | ||
| return Object.entries(command?.flags ?? root?.flags ?? {}).flatMap( | ||
| ([name, flag]) => | ||
| flag.hidden || !SAFE_WORD.test(name) | ||
| ? [] | ||
| : [ | ||
| [`--${name}`, flag] as [string, CompletionFlag], | ||
| ...(flag.type === 'boolean' && flag.allowNo | ||
| ? [[`--no-${name}`, flag] as [string, CompletionFlag]] | ||
| : []), | ||
| ...(flag.char === undefined || !SAFE_WORD.test(flag.char) | ||
| ? [] | ||
| : [[`-${flag.char}`, flag] as [string, CompletionFlag]]), | ||
| ], | ||
| ) | ||
| } | ||
|
|
||
| function safeCandidates(values: readonly string[], prefix: string): string[] { | ||
| return [ | ||
| ...new Set( | ||
| values.filter( | ||
| (value) => | ||
| SAFE_WORD.test(value.replace(/^-+/, '')) && value.startsWith(prefix), | ||
| ), | ||
| ), | ||
| ].sort() | ||
| } | ||
|
|
||
| export type CompletionShell = 'bash' | 'fish' | 'zsh' | ||
|
|
||
| export function completionScript(shell: CompletionShell): string { | ||
| switch (shell) { | ||
| case 'bash': | ||
| return `# Polymorfa Bash completion. Suggestions use only local command metadata. | ||
| _polymorfa_completion() { | ||
| local current="\${COMP_WORDS[COMP_CWORD]}" | ||
| local -a prior=("\${COMP_WORDS[@]:1:$((COMP_CWORD-1))}") | ||
|
purpshell marked this conversation as resolved.
|
||
| local output candidate option_prefix="" | ||
| if (( COMP_CWORD >= 3 )) && [[ \${COMP_WORDS[COMP_CWORD-1]} == '=' && \${COMP_WORDS[COMP_CWORD-2]} == --* ]]; then | ||
| option_prefix="\${COMP_WORDS[COMP_CWORD-2]}=" | ||
| current="$option_prefix$current" | ||
| prior=("\${COMP_WORDS[@]:1:$((COMP_CWORD-3))}") | ||
| elif (( COMP_CWORD >= 2 )) && [[ $current == '=' && \${COMP_WORDS[COMP_CWORD-1]} == --* ]]; then | ||
| option_prefix="\${COMP_WORDS[COMP_CWORD-1]}=" | ||
| current="$option_prefix" | ||
| prior=("\${COMP_WORDS[@]:1:$((COMP_CWORD-2))}") | ||
| fi | ||
| COMPREPLY=() | ||
| output="$(command polymorfa completion suggest --current="$current" -- "\${prior[@]}" 2>/dev/null)" || return | ||
| while IFS= read -r candidate; do | ||
| [[ -n "$candidate" ]] || continue | ||
| if [[ -n "$option_prefix" && $candidate == "$option_prefix"* ]]; then | ||
| candidate="\${candidate#"$option_prefix"}" | ||
| fi | ||
| COMPREPLY+=("$candidate") | ||
| done <<< "$output" | ||
| } | ||
| complete -o bashdefault -o default -F _polymorfa_completion polymorfa` | ||
| case 'zsh': | ||
| return `#compdef polymorfa | ||
| # Polymorfa Zsh completion. Suggestions use only local command metadata. | ||
| _polymorfa_completion() { | ||
| local -a prior candidates | ||
| local output i | ||
| for (( i = 2; i < CURRENT; i++ )); do | ||
| prior+=("\${words[i]}") | ||
| done | ||
| output="$(command polymorfa completion suggest --current="\${words[CURRENT]}" -- "\${prior[@]}" 2>/dev/null)" || return | ||
| [[ -n "$output" ]] || return | ||
| candidates=("\${(@f)output}") | ||
| compadd -- "\${candidates[@]}" | ||
| } | ||
| compdef _polymorfa_completion polymorfa` | ||
| case 'fish': | ||
| return `# Polymorfa Fish completion. Suggestions use only local command metadata. | ||
| function __polymorfa_completion | ||
| set -l prior (commandline -xpc) | ||
|
purpshell marked this conversation as resolved.
|
||
| set -e prior[1] | ||
| set -l current (commandline -ct) | ||
| command polymorfa completion suggest --current="$current" -- $prior 2>/dev/null | ||
| end | ||
| complete -c polymorfa -f -a '(__polymorfa_completion)'` | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import {runCommand} from '@oclif/test' | ||
| import {AsyncEntry} from '@napi-rs/keyring' | ||
| import {afterEach, describe, expect, test, vi} from 'vitest' | ||
|
|
||
| describe('completion commands', () => { | ||
| afterEach(() => vi.restoreAllMocks()) | ||
|
|
||
| test('prints scripts for each supported shell', async () => { | ||
| for (const shell of ['bash', 'zsh', 'fish']) { | ||
| const result = await runCommand(`completion ${shell}`) | ||
| expect(result.error, result.stderr).toBeUndefined() | ||
| expect(result.stdout).toContain('polymorfa completion suggest') | ||
| } | ||
| }) | ||
|
|
||
| test('suggests local commands and fixed flags without auth or network calls', async () => { | ||
| const fetch = vi.spyOn(globalThis, 'fetch') | ||
| const keyring = vi.spyOn(AsyncEntry.prototype, 'getPassword') | ||
| const command = await runCommand('completion suggest --current=lo -- auth') | ||
| expect(command.error, command.stderr).toBeUndefined() | ||
| expect(command.stdout.split('\n')).toContain('login') | ||
| const flags = await runCommand( | ||
| 'completion suggest --current=--j -- auth login', | ||
| ) | ||
| expect(flags.error, flags.stderr).toBeUndefined() | ||
| expect(flags.stdout.split('\n')).toContain('--json') | ||
| const negated = await runCommand( | ||
| 'completion suggest --current=--no- -- calls settings set', | ||
| ) | ||
| expect(negated.error, negated.stderr).toBeUndefined() | ||
| expect(negated.stdout.split('\n')).toContain('--no-conference-mode') | ||
| expect(fetch).not.toHaveBeenCalled() | ||
| expect(keyring).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.