1// Each command has a completion function that takes an options object and a cb 2// The callback gets called with an error and an array of possible completions. 3// The options object is built up based on the environment variables set by 4// zsh or bash when calling a function for completion, based on the cursor 5// position and the command line thus far. These are: 6// COMP_CWORD: the index of the "word" in the command line being completed 7// COMP_LINE: the full command line thusfar as a string 8// COMP_POINT: the cursor index at the point of triggering completion 9// 10// We parse the command line with nopt, like npm does, and then create an 11// options object containing: 12// words: array of words in the command line 13// w: the index of the word being completed (ie, COMP_CWORD) 14// word: the word being completed 15// line: the COMP_LINE 16// lineLength 17// point: the COMP_POINT, usually equal to line length, but not always, eg if 18// the user has pressed the left-arrow to complete an earlier word 19// partialLine: the line up to the point 20// partialWord: the word being completed (which might be ''), up to the point 21// conf: a nopt parse of the command line 22// 23// When the implementation completion method returns its list of strings, 24// and arrays of strings, we filter that by any that start with the 25// partialWord, since only those can possibly be valid matches. 26// 27// Matches are wrapped with ' to escape them, if necessary, and then printed 28// one per line for the shell completion method to consume in IFS=$'\n' mode 29// as an array. 30// 31 32const fs = require('fs/promises') 33const nopt = require('nopt') 34const { resolve } = require('path') 35 36const Npm = require('../npm.js') 37const { definitions, shorthands } = require('@npmcli/config/lib/definitions') 38const { commands, aliases, deref } = require('../utils/cmd-list.js') 39const configNames = Object.keys(definitions) 40const shorthandNames = Object.keys(shorthands) 41const allConfs = configNames.concat(shorthandNames) 42const { isWindowsShell } = require('../utils/is-windows.js') 43const fileExists = (file) => fs.stat(file).then(s => s.isFile()).catch(() => false) 44 45const BaseCommand = require('../base-command.js') 46 47class Completion extends BaseCommand { 48 static description = 'Tab Completion for npm' 49 static name = 'completion' 50 51 // completion for the completion command 52 static async completion (opts) { 53 if (opts.w > 2) { 54 return 55 } 56 57 const [bashExists, zshExists] = await Promise.all([ 58 fileExists(resolve(process.env.HOME, '.bashrc')), 59 fileExists(resolve(process.env.HOME, '.zshrc')), 60 ]) 61 const out = [] 62 if (zshExists) { 63 out.push(['>>', '~/.zshrc']) 64 } 65 66 if (bashExists) { 67 out.push(['>>', '~/.bashrc']) 68 } 69 70 return out 71 } 72 73 async exec (args) { 74 if (isWindowsShell) { 75 const msg = 'npm completion supported only in MINGW / Git bash on Windows' 76 throw Object.assign(new Error(msg), { 77 code: 'ENOTSUP', 78 }) 79 } 80 81 const { COMP_CWORD, COMP_LINE, COMP_POINT, COMP_FISH } = process.env 82 83 // if the COMP_* isn't in the env, then just dump the script. 84 if (COMP_CWORD === undefined || COMP_LINE === undefined || COMP_POINT === undefined) { 85 return dumpScript(resolve(this.npm.npmRoot, 'lib', 'utils', 'completion.sh')) 86 } 87 88 // ok we're actually looking at the envs and outputting the suggestions 89 // get the partial line and partial word, 90 // if the point isn't at the end. 91 // ie, tabbing at: npm foo b|ar 92 const w = +COMP_CWORD 93 const words = args.map(unescape) 94 const word = words[w] 95 const line = COMP_LINE 96 const point = +COMP_POINT 97 const partialLine = line.slice(0, point) 98 const partialWords = words.slice(0, w) 99 100 // figure out where in that last word the point is. 101 const partialWordRaw = args[w] 102 let i = partialWordRaw.length 103 while (partialWordRaw.slice(0, i) !== partialLine.slice(-1 * i) && i > 0) { 104 i-- 105 } 106 107 const partialWord = unescape(partialWordRaw.slice(0, i)) 108 partialWords.push(partialWord) 109 110 const opts = { 111 isFish: COMP_FISH === 'true', 112 words, 113 w, 114 word, 115 line, 116 lineLength: line.length, 117 point, 118 partialLine, 119 partialWords, 120 partialWord, 121 raw: args, 122 } 123 124 if (partialWords.slice(0, -1).indexOf('--') === -1) { 125 if (word.charAt(0) === '-') { 126 return this.wrap(opts, configCompl(opts)) 127 } 128 129 if (words[w - 1] && 130 words[w - 1].charAt(0) === '-' && 131 !isFlag(words[w - 1])) { 132 // awaiting a value for a non-bool config. 133 // don't even try to do this for now 134 return this.wrap(opts, configValueCompl(opts)) 135 } 136 } 137 138 // try to find the npm command. 139 // it's the first thing after all the configs. 140 // take a little shortcut and use npm's arg parsing logic. 141 // don't have to worry about the last arg being implicitly 142 // boolean'ed, since the last block will catch that. 143 const types = Object.entries(definitions).reduce((acc, [key, def]) => { 144 acc[key] = def.type 145 return acc 146 }, {}) 147 const parsed = opts.conf = 148 nopt(types, shorthands, partialWords.slice(0, -1), 0) 149 // check if there's a command already. 150 const cmd = parsed.argv.remain[1] 151 if (!cmd) { 152 return this.wrap(opts, cmdCompl(opts, this.npm)) 153 } 154 155 Object.keys(parsed).forEach(k => this.npm.config.set(k, parsed[k])) 156 157 // at this point, if words[1] is some kind of npm command, 158 // then complete on it. 159 // otherwise, do nothing 160 try { 161 const { completion } = Npm.cmd(cmd) 162 if (completion) { 163 const comps = await completion(opts, this.npm) 164 return this.wrap(opts, comps) 165 } 166 } catch { 167 // it wasnt a valid command, so do nothing 168 } 169 } 170 171 // The command should respond with an array. Loop over that, 172 // wrapping quotes around any that have spaces, and writing 173 // them to stdout. 174 // If any of the items are arrays, then join them with a space. 175 // Ie, returning ['a', 'b c', ['d', 'e']] would allow it to expand 176 // to: 'a', 'b c', or 'd' 'e' 177 wrap (opts, compls) { 178 // TODO this was dead code, leaving it in case we find some command we 179 // forgot that requires this. if so *that command should fix its 180 // completions* 181 // compls = compls.map(w => !/\s+/.test(w) ? w : '\'' + w + '\'') 182 183 if (opts.partialWord) { 184 compls = compls.filter(c => c.startsWith(opts.partialWord)) 185 } 186 187 if (compls.length > 0) { 188 this.npm.output(compls.join('\n')) 189 } 190 } 191} 192 193const dumpScript = async (p) => { 194 const d = (await fs.readFile(p, 'utf8')).replace(/^#!.*?\n/, '') 195 await new Promise((res, rej) => { 196 let done = false 197 process.stdout.on('error', er => { 198 if (done) { 199 return 200 } 201 202 done = true 203 204 // Darwin is a pain sometimes. 205 // 206 // This is necessary because the "source" or "." program in 207 // bash on OS X closes its file argument before reading 208 // from it, meaning that you get exactly 1 write, which will 209 // work most of the time, and will always raise an EPIPE. 210 // 211 // Really, one should not be tossing away EPIPE errors, or any 212 // errors, so casually. But, without this, `. <(npm completion)` 213 // can never ever work on OS X. 214 // TODO Ignoring coverage, see 'non EPIPE errors cause failures' test. 215 /* istanbul ignore next */ 216 if (er.errno === 'EPIPE') { 217 res() 218 } else { 219 rej(er) 220 } 221 }) 222 223 process.stdout.write(d, () => { 224 if (done) { 225 return 226 } 227 228 done = true 229 res() 230 }) 231 }) 232} 233 234const unescape = w => w.charAt(0) === '\'' ? w.replace(/^'|'$/g, '') 235 : w.replace(/\\ /g, ' ') 236 237// the current word has a dash. Return the config names, 238// with the same number of dashes as the current word has. 239const configCompl = opts => { 240 const word = opts.word 241 const split = word.match(/^(-+)((?:no-)*)(.*)$/) 242 const dashes = split[1] 243 const no = split[2] 244 const flags = configNames.filter(isFlag) 245 return allConfs.map(c => dashes + c) 246 .concat(flags.map(f => dashes + (no || 'no-') + f)) 247} 248 249// expand with the valid values of various config values. 250// not yet implemented. 251const configValueCompl = opts => [] 252 253// check if the thing is a flag or not. 254const isFlag = word => { 255 // shorthands never take args. 256 const split = word.match(/^(-*)((?:no-)+)?(.*)$/) 257 const no = split[2] 258 const conf = split[3] 259 const { type } = definitions[conf] 260 return no || 261 type === Boolean || 262 (Array.isArray(type) && type.includes(Boolean)) || 263 shorthands[conf] 264} 265 266// complete against the npm commands 267// if they all resolve to the same thing, just return the thing it already is 268const cmdCompl = (opts, npm) => { 269 const allCommands = commands.concat(Object.keys(aliases)) 270 const matches = allCommands.filter(c => c.startsWith(opts.partialWord)) 271 if (!matches.length) { 272 return matches 273 } 274 275 const derefs = new Set([...matches.map(c => deref(c))]) 276 if (derefs.size === 1) { 277 return [...derefs] 278 } 279 280 return allCommands 281} 282 283module.exports = Completion 284