Skip to content

Commit

Permalink
feat: zsh auto completion (#1292)
Browse files Browse the repository at this point in the history
fixes: #1156
  • Loading branch information
trevorlinton authored and bcoe committed Feb 14, 2019
1 parent 706fc7a commit 16c5d25
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 16 deletions.
17 changes: 17 additions & 0 deletions completion.zsh.hbs
@@ -0,0 +1,17 @@
###-begin-{{app_name}}-completions-###
#
# yargs command completion script
#
# Installation: {{app_path}} {{completion_command}} >> ~/.zshrc
# or {{app_path}} {{completion_command}} >> ~/.zsh_profile on OSX.
#
_{{app_name}}_yargs_completions()
{
local reply
local si=$IFS
IFS=$'\n' reply=($(COMP_CWORD="$((CURRENT-1))" COMP_LINE="$BUFFER" COMP_POINT="$CURSOR" {{app_path}} --get-yargs-completions "$\{words[@]\}"))
IFS=$si
_describe 'values' reply
}
compdef _{{app_name}}_yargs_completions {{app_name}}
###-end-{{app_name}}-completions-###
10 changes: 5 additions & 5 deletions docs/api.md
Expand Up @@ -271,14 +271,14 @@ discussion of the advanced features exposed in the Command API.
.completion([cmd], [description], [fn])
---------------------------------------

Enable bash-completion shortcuts for commands and options.
Enable bash/zsh-completion shortcuts for commands and options.

`cmd`: When present in `argv._`, will result in the `.bashrc` completion script
being outputted. To enable bash completions, concat the generated script to your
`.bashrc` or `.bash_profile`.
`cmd`: When present in `argv._`, will result in the `.bashrc` or `.zshrc` completion script
being outputted. To enable bash/zsh completions, concat the generated script to your
`.bashrc` or `.bash_profile` (or `.zshrc` for zsh).

`description`: Provide a description in your usage instructions for the command
that generates bash completion scripts.
that generates the completion scripts.

`fn`: Rather than relying on yargs' default completion functionality, which
shiver me timbers is pretty awesome, you can provide your own completion
Expand Down
18 changes: 15 additions & 3 deletions lib/completion.js
Expand Up @@ -9,6 +9,7 @@ module.exports = function completion (yargs, usage, command) {
completionKey: 'get-yargs-completions'
}

const zshShell = process.env.SHELL && process.env.SHELL.indexOf('zsh') !== -1
// get a list of completion commands.
// 'args' is the array of strings from the line to be completed
self.getCompletion = function getCompletion (args, done) {
Expand Down Expand Up @@ -58,18 +59,29 @@ module.exports = function completion (yargs, usage, command) {
usage.getCommands().forEach((usageCommand) => {
const commandName = command.parseCommand(usageCommand[0]).cmd
if (args.indexOf(commandName) === -1) {
completions.push(commandName)
if (!zshShell) {
completions.push(commandName)
} else {
const desc = usageCommand[1] || ''
completions.push(commandName.replace(/:/g, '\\:') + ':' + desc)
}
}
})
}

if (current.match(/^-/)) {
const descs = usage.getDescriptions()
Object.keys(yargs.getOptions().key).forEach((key) => {
// If the key and its aliases aren't in 'args', add the key to 'completions'
const keyAndAliases = [key].concat(aliases[key] || [])
const notInArgs = keyAndAliases.every(val => args.indexOf(`--${val}`) === -1)
if (notInArgs) {
completions.push(`--${key}`)
if (!zshShell) {
completions.push(`--${key}`)
} else {
const desc = descs[key] || ''
completions.push(`--${key.replace(/:/g, '\\:')}:${desc}`)
}
}
})
}
Expand All @@ -80,7 +92,7 @@ module.exports = function completion (yargs, usage, command) {
// generate the completion script to add to your .bashrc.
self.generateCompletionScript = function generateCompletionScript ($0, cmd) {
let script = fs.readFileSync(
path.resolve(__dirname, '../completion.sh.hbs'),
path.resolve(__dirname, zshShell ? '../completion.zsh.hbs' : '../completion.sh.hbs'),
'utf-8'
)
const name = path.basename($0)
Expand Down
139 changes: 132 additions & 7 deletions test/completion.js
Expand Up @@ -14,7 +14,8 @@ describe('Completion', () => {
})

describe('default completion behavior', () => {
it('it returns a list of commands as completion suggestions', () => {
it('it returns a list of commands as completion suggestions (bash)', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() => yargs(['./completion', '--get-yargs-completions', ''])
.command('foo', 'bar')
.command('apple', 'banana')
Expand All @@ -26,7 +27,21 @@ describe('Completion', () => {
r.logs.should.include('foo')
})

it('avoids interruption from command recommendations', () => {
it('it returns a list of commands as completion suggestions (zsh)', () => {
process.env.SHELL = '/bin/zsh'
const r = checkUsage(() => yargs(['./completion', '--get-yargs-completions', ''])
.command('foo', 'bar')
.command('apple', 'banana')
.completion()
.argv
)

r.logs.should.include('apple:banana')
r.logs.should.include('foo:bar')
})

it('avoids interruption from command recommendations (bash)', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() =>
yargs(['./completion', '--get-yargs-completions', './completion', 'a'])
.command('apple', 'fruit')
Expand All @@ -41,7 +56,24 @@ describe('Completion', () => {
r.logs.should.include('aardvark')
})

it('avoids interruption from default command', () => {
it('avoids interruption from command recommendations (zsh)', () => {
process.env.SHELL = '/bin/zsh'
const r = checkUsage(() =>
yargs(['./completion', '--get-yargs-completions', './completion', 'a'])
.command('apple', 'fruit')
.command('aardvark', 'animal')
.recommendCommands()
.completion()
.argv
)

r.errors.should.deep.equal([])
r.logs.should.include('apple:fruit')
r.logs.should.include('aardvark:animal')
})

it('avoids interruption from default command (bash)', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() =>
yargs(['./usage', '--get-yargs-completions', './usage', ''])
.usage('$0 [thing]', 'skipped', subYargs => {
Expand All @@ -57,7 +89,25 @@ describe('Completion', () => {
r.logs.should.include('aardvark')
})

it('avoids interruption from default command (zsh)', () => {
process.env.SHELL = '/bin/zsh'
const r = checkUsage(() =>
yargs(['./usage', '--get-yargs-completions', './usage', ''])
.usage('$0 [thing]', 'skipped', subYargs => {
subYargs.command('aardwolf', 'is a thing according to google')
})
.command('aardvark', 'animal')
.completion()
.argv
)

r.errors.should.deep.equal([])
r.logs.should.not.include('aardwolf')
r.logs.should.include('aardvark:animal')
})

it('avoids repeating already included commands', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() => yargs(['./completion', '--get-yargs-completions', 'apple'])
.command('foo', 'bar')
.command('apple', 'banana')
Expand Down Expand Up @@ -96,7 +146,8 @@ describe('Completion', () => {
r.logs.should.not.include('--foo')
})

it('completes options for a command', () => {
it('completes options for a command (bash)', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() => yargs(['./completion', '--get-yargs-completions', 'foo', '--b'])
.command('foo', 'foo command', subYargs => subYargs.options({
bar: {
Expand All @@ -114,7 +165,27 @@ describe('Completion', () => {
r.logs.should.include('--help')
})

it('completes options for a command (zsh)', () => {
process.env.SHELL = '/bin/zsh'
const r = checkUsage(() => yargs(['./completion', '--get-yargs-completions', 'foo', '--b'])
.command('foo', 'foo command', subYargs => subYargs.options({
bar: {
describe: 'bar option'
}
})
.help(true)
.version(false))
.completion()
.argv
)

r.logs.should.have.length(2)
r.logs.should.include('--bar:bar option')
r.logs.should.include('--help:__yargsString__:Show help')
})

it('completes options for the correct command', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() => yargs(['./completion', '--get-yargs-completions', 'cmd2', '--o'])
.help(false)
.version(false)
Expand All @@ -141,6 +212,7 @@ describe('Completion', () => {
})

it('does not complete hidden commands', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() => yargs(['./completion', '--get-yargs-completions', 'cmd'])
.command('cmd1', 'first command')
.command('cmd2', false)
Expand All @@ -153,6 +225,7 @@ describe('Completion', () => {
})

it('does not include possitional arguments', function () {
process.env.SHELL = '/bin/bash'
var r = checkUsage(function () {
return yargs(['./completion', '--get-yargs-completions', 'cmd'])
.command('cmd1 [arg]', 'first command')
Expand All @@ -167,6 +240,7 @@ describe('Completion', () => {
})

it('works if command has no options', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() => yargs(['./completion', '--get-yargs-completions', 'foo', '--b'])
.help(false)
.version(false)
Expand All @@ -181,6 +255,7 @@ describe('Completion', () => {
})

it("returns arguments as completion suggestion, if next contains '-'", () => {
process.env.SHELL = '/bin/basg'
const r = checkUsage(() => yargs(['./usage', '--get-yargs-completions', '-f'])
.option('foo', {
describe: 'foo option'
Expand All @@ -196,10 +271,21 @@ describe('Completion', () => {
})

describe('generateCompletionScript()', () => {
it('replaces application variable with $0 in script', () => {
it('replaces application variable with $0 in script (bash)', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() => yargs([])
.showCompletionScript(), ['ndm'])

r.logs[0].should.match(/bashrc/)
r.logs[0].should.match(/ndm --get-yargs-completions/)
})

it('replaces application variable with $0 in script (zsh)', () => {
process.env.SHELL = '/bin/zsh'
const r = checkUsage(() => yargs([])
.showCompletionScript(), ['ndm'])

r.logs[0].should.match(/zshrc/)
r.logs[0].should.match(/ndm --get-yargs-completions/)
})

Expand Down Expand Up @@ -299,8 +385,9 @@ describe('Completion', () => {
})
})

describe('getCompletion()', () => {
describe('getCompletion() - bash', () => {
it('returns default completion to callback', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() => {
yargs()
.command('foo', 'bar')
Expand All @@ -318,8 +405,29 @@ describe('Completion', () => {
})
})

describe('getCompletion() - zsh', () => {
it('returns default completion to callback', () => {
process.env.SHELL = '/bin/zsh'
const r = checkUsage(() => {
yargs()
.command('foo', 'bar')
.command('apple', 'banana')
.completion()
.getCompletion([''], (completions) => {
;(completions || []).forEach((completion) => {
console.log(completion)
})
})
})

r.logs.should.include('apple:banana')
r.logs.should.include('foo:bar')
})
})

// fixes for #177.
it('does not apply validation when --get-yargs-completions is passed in', () => {
it('does not apply validation when --get-yargs-completions is passed in (bash)', () => {
process.env.SHELL = '/bin/bash'
const r = checkUsage(() => {
try {
return yargs(['./completion', '--get-yargs-completions', '--'])
Expand All @@ -335,4 +443,21 @@ describe('Completion', () => {
r.errors.length.should.equal(0)
r.logs.should.include('--foo')
})
it('does not apply validation when --get-yargs-completions is passed in (zsh)', () => {
process.env.SHELL = '/bin/zsh'
const r = checkUsage(() => {
try {
return yargs(['./completion', '--get-yargs-completions', '--'])
.option('foo', {'describe': 'bar'})
.completion()
.strict()
.argv
} catch (e) {
console.log(e.message)
}
})

r.errors.length.should.equal(0)
r.logs.should.include('--foo:bar')
})
})
2 changes: 1 addition & 1 deletion yargs.js
Expand Up @@ -903,7 +903,7 @@ function Yargs (processArgs, cwd, parentRequire) {
// register the completion command.
completionCommand = cmd || 'completion'
if (!desc && desc !== false) {
desc = 'generate bash completion script'
desc = 'generate completion script'
}
self.command(completionCommand, desc)

Expand Down

1 comment on commit 16c5d25

@thom4parisot
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! Thank you so much 🙇‍♂️

Please sign in to comment.