diff --git a/lib/commands/completion.js b/lib/commands/completion.js index f5604e099f9a2..49a66627cca2c 100644 --- a/lib/commands/completion.js +++ b/lib/commands/completion.js @@ -79,12 +79,10 @@ class Completion extends BaseCommand { }) } - const { COMP_CWORD, COMP_LINE, COMP_POINT } = process.env + const { COMP_CWORD, COMP_LINE, COMP_POINT, COMP_FISH } = process.env // if the COMP_* isn't in the env, then just dump the script. - if (COMP_CWORD === undefined || - COMP_LINE === undefined || - COMP_POINT === undefined) { + if (COMP_CWORD === undefined || COMP_LINE === undefined || COMP_POINT === undefined) { return dumpScript(resolve(this.npm.npmRoot, 'lib', 'utils', 'completion.sh')) } @@ -111,6 +109,7 @@ class Completion extends BaseCommand { partialWords.push(partialWord) const opts = { + isFish: COMP_FISH === 'true', words, w, word, diff --git a/lib/commands/run-script.js b/lib/commands/run-script.js index 51746c5e5285d..40e18e1ea0644 100644 --- a/lib/commands/run-script.js +++ b/lib/commands/run-script.js @@ -51,6 +51,9 @@ class RunScript extends BaseCommand { // find the script name const json = resolve(this.npm.localPrefix, 'package.json') const { scripts = {} } = await rpj(json).catch(er => ({})) + if (opts.isFish) { + return Object.keys(scripts).map(s => `${s}\t${scripts[s].slice(0, 30)}`) + } return Object.keys(scripts) } } diff --git a/lib/utils/completion.fish b/lib/utils/completion.fish new file mode 100644 index 0000000000000..5e274ad77e5fd --- /dev/null +++ b/lib/utils/completion.fish @@ -0,0 +1,40 @@ +# npm completions for Fish shell +# This script is a work in progress and does not fall under the normal semver contract as the rest of npm. + +# __fish_npm_needs_command taken from: +# https://stackoverflow.com/questions/16657803/creating-autocomplete-script-with-sub-commands +function __fish_npm_needs_command + set -l cmd (commandline -opc) + + if test (count $cmd) -eq 1 + return 0 + end + + return 1 +end + +# Taken from https://github.com/fish-shell/fish-shell/blob/HEAD/share/completions/npm.fish +function __fish_complete_npm -d "Complete the commandline using npm's 'completion' tool" + # tell npm we are fish shell + set -lx COMP_FISH true + if command -sq npm + # npm completion is bash-centric, so we need to translate fish's "commandline" stuff to bash's $COMP_* stuff + # COMP_LINE is an array with the words in the commandline + set -lx COMP_LINE (commandline -opc) + # COMP_CWORD is the index of the current word in COMP_LINE + # bash starts arrays with 0, so subtract 1 + set -lx COMP_CWORD (math (count $COMP_LINE) - 1) + # COMP_POINT is the index of point/cursor when the commandline is viewed as a string + set -lx COMP_POINT (commandline -C) + # If the cursor is after the last word, the empty token will disappear in the expansion + # Readd it + if test (commandline -ct) = "" + set COMP_CWORD (math $COMP_CWORD + 1) + set COMP_LINE $COMP_LINE "" + end + command npm completion -- $COMP_LINE 2>/dev/null + end +end + +# flush out what ships with fish +complete -e npm diff --git a/scripts/fish-completion.js b/scripts/fish-completion.js new file mode 100644 index 0000000000000..6357c1032fe56 --- /dev/null +++ b/scripts/fish-completion.js @@ -0,0 +1,54 @@ +/* eslint-disable no-console */ +const fs = require('fs/promises') +const { resolve } = require('path') + +const { commands, aliases } = require('../lib/utils/cmd-list.js') +const { definitions } = require('../lib/utils/config/index.js') + +async function main () { + const file = resolve(__dirname, '..', 'lib', 'utils', 'completion.fish') + console.log(await fs.readFile(file, 'utf-8')) + const cmds = {} + for (const cmd of commands) { + cmds[cmd] = { aliases: [cmd] } + const cmdClass = require(`../lib/commands/${cmd}.js`) + cmds[cmd].description = cmdClass.description + cmds[cmd].params = cmdClass.params + } + for (const alias in aliases) { + cmds[aliases[alias]].aliases.push(alias) + } + for (const cmd in cmds) { + console.log(`# ${cmd}`) + const { aliases: cmdAliases, description, params = [] } = cmds[cmd] + // If npm completion could return all commands in a fish friendly manner + // like we do w/ run-script these wouldn't be needed. + /* eslint-disable-next-line max-len */ + console.log(`complete -x -c npm -n __fish_npm_needs_command -a '${cmdAliases.join(' ')}' -d '${description}'`) + const shorts = params.map(p => { + // Our multi-character short params (e.g. -ws) are not very standard and + // don't work with things that assume short params are only ever single + // characters. + if (definitions[p].short?.length === 1) { + return `-s ${definitions[p].short}` + } + }).filter(p => p).join(' ') + // The config descriptions are not appropriate for -d here. We may want to + // consider having a more terse description for these. + // We can also have a mechanism to auto-generate the long form of options + // that have predefined values. + // params completion + /* eslint-disable-next-line max-len */ + console.log(`complete -x -c npm -n '__fish_seen_subcommand_from ${cmdAliases.join(' ')}' ${params.map(p => `-l ${p}`).join(' ')} ${shorts}`) + // builtin npm completion + /* eslint-disable-next-line max-len */ + console.log(`complete -x -c npm -n '__fish_seen_subcommand_from ${cmdAliases.join(' ')}' -a '(__fish_complete_npm)'`) + } +} + +main().then(() => { + return process.exit() +}).catch(err => { + console.error(err) + process.exit(1) +}) diff --git a/test/lib/commands/run-script.js b/test/lib/commands/run-script.js index a265db3cc040d..6e2bf22adddcf 100644 --- a/test/lib/commands/run-script.js +++ b/test/lib/commands/run-script.js @@ -34,12 +34,12 @@ const mockRs = async (t, { windows = false, runScript, ...opts } = {}) => { } t.test('completion', async t => { - const completion = async (t, remain, pkg) => { + const completion = async (t, remain, pkg, isFish = false) => { const { npm } = await mockRs(t, pkg ? { prefixDir: { 'package.json': JSON.stringify(pkg) } } : {} ) const cmd = await npm.cmd('run-script') - return cmd.completion({ conf: { argv: { remain } } }) + return cmd.completion({ conf: { argv: { remain } }, isFish }) } t.test('already have a script name', async t => { @@ -60,6 +60,13 @@ t.test('completion', async t => { }) t.strictSame(res, ['hello', 'world']) }) + + t.test('fish shell', async t => { + const res = await completion(t, ['npm', 'run'], { + scripts: { hello: 'echo hello', world: 'echo world' }, + }, true) + t.strictSame(res, ['hello\techo hello', 'world\techo world']) + }) }) t.test('fail if no package.json', async t => {