• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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