completion.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  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. const { definitions, shorthands } = require('./utils/config/index.js')
  32. const deref = require('./utils/deref-command.js')
  33. const { aliases, cmdList, plumbing } = require('./utils/cmd-list.js')
  34. const aliasNames = Object.keys(aliases)
  35. const fullList = cmdList.concat(aliasNames).filter(c => !plumbing.includes(c))
  36. const nopt = require('nopt')
  37. const configNames = Object.keys(definitions)
  38. const shorthandNames = Object.keys(shorthands)
  39. const allConfs = configNames.concat(shorthandNames)
  40. const isWindowsShell = require('./utils/is-windows-shell.js')
  41. const fileExists = require('./utils/file-exists.js')
  42. const { promisify } = require('util')
  43. const BaseCommand = require('./base-command.js')
  44. class Completion extends BaseCommand {
  45. /* istanbul ignore next - see test/lib/load-all-commands.js */
  46. static get description () {
  47. return 'Tab Completion for npm'
  48. }
  49. /* istanbul ignore next - see test/lib/load-all-commands.js */
  50. static get name () {
  51. return 'completion'
  52. }
  53. // completion for the completion command
  54. async completion (opts) {
  55. if (opts.w > 2)
  56. return
  57. const { resolve } = require('path')
  58. const [bashExists, zshExists] = await Promise.all([
  59. fileExists(resolve(process.env.HOME, '.bashrc')),
  60. fileExists(resolve(process.env.HOME, '.zshrc')),
  61. ])
  62. const out = []
  63. if (zshExists)
  64. out.push(['>>', '~/.zshrc'])
  65. if (bashExists)
  66. out.push(['>>', '~/.bashrc'])
  67. return out
  68. }
  69. exec (args, cb) {
  70. this.compl(args).then(() => cb()).catch(cb)
  71. }
  72. async compl (args) {
  73. if (isWindowsShell) {
  74. const msg = 'npm completion supported only in MINGW / Git bash on Windows'
  75. throw Object.assign(new Error(msg), {
  76. code: 'ENOTSUP',
  77. })
  78. }
  79. const { COMP_CWORD, COMP_LINE, COMP_POINT } = process.env
  80. // if the COMP_* isn't in the env, then just dump the script.
  81. if (COMP_CWORD === undefined ||
  82. COMP_LINE === undefined ||
  83. COMP_POINT === undefined)
  84. return dumpScript()
  85. // ok we're actually looking at the envs and outputting the suggestions
  86. // get the partial line and partial word,
  87. // if the point isn't at the end.
  88. // ie, tabbing at: npm foo b|ar
  89. const w = +COMP_CWORD
  90. const words = args.map(unescape)
  91. const word = words[w]
  92. const line = COMP_LINE
  93. const point = +COMP_POINT
  94. const partialLine = line.substr(0, point)
  95. const partialWords = words.slice(0, w)
  96. // figure out where in that last word the point is.
  97. const partialWordRaw = args[w]
  98. let i = partialWordRaw.length
  99. while (partialWordRaw.substr(0, i) !== partialLine.substr(-1 * i) && i > 0)
  100. i--
  101. const partialWord = unescape(partialWordRaw.substr(0, i))
  102. partialWords.push(partialWord)
  103. const opts = {
  104. words,
  105. w,
  106. word,
  107. line,
  108. lineLength: line.length,
  109. point,
  110. partialLine,
  111. partialWords,
  112. partialWord,
  113. raw: args,
  114. }
  115. if (partialWords.slice(0, -1).indexOf('--') === -1) {
  116. if (word.charAt(0) === '-')
  117. return this.wrap(opts, configCompl(opts))
  118. if (words[w - 1] &&
  119. words[w - 1].charAt(0) === '-' &&
  120. !isFlag(words[w - 1])) {
  121. // awaiting a value for a non-bool config.
  122. // don't even try to do this for now
  123. return this.wrap(opts, configValueCompl(opts))
  124. }
  125. }
  126. // try to find the npm command.
  127. // it's the first thing after all the configs.
  128. // take a little shortcut and use npm's arg parsing logic.
  129. // don't have to worry about the last arg being implicitly
  130. // boolean'ed, since the last block will catch that.
  131. const types = Object.entries(definitions).reduce((types, [key, def]) => {
  132. types[key] = def.type
  133. return types
  134. }, {})
  135. const parsed = opts.conf =
  136. nopt(types, shorthands, partialWords.slice(0, -1), 0)
  137. // check if there's a command already.
  138. const cmd = parsed.argv.remain[1]
  139. if (!cmd)
  140. return this.wrap(opts, cmdCompl(opts))
  141. Object.keys(parsed).forEach(k => this.npm.config.set(k, parsed[k]))
  142. // at this point, if words[1] is some kind of npm command,
  143. // then complete on it.
  144. // otherwise, do nothing
  145. const impl = this.npm.commands[cmd]
  146. if (impl && impl.completion) {
  147. const comps = await impl.completion(opts)
  148. return this.wrap(opts, comps)
  149. }
  150. }
  151. // The command should respond with an array. Loop over that,
  152. // wrapping quotes around any that have spaces, and writing
  153. // them to stdout.
  154. // If any of the items are arrays, then join them with a space.
  155. // Ie, returning ['a', 'b c', ['d', 'e']] would allow it to expand
  156. // to: 'a', 'b c', or 'd' 'e'
  157. wrap (opts, compls) {
  158. if (!Array.isArray(compls))
  159. compls = compls ? [compls] : []
  160. compls = compls.map(c =>
  161. Array.isArray(c) ? c.map(escape).join(' ') : escape(c))
  162. if (opts.partialWord)
  163. compls = compls.filter(c => c.startsWith(opts.partialWord))
  164. if (compls.length > 0)
  165. this.npm.output(compls.join('\n'))
  166. }
  167. }
  168. const dumpScript = async () => {
  169. const fs = require('fs')
  170. const readFile = promisify(fs.readFile)
  171. const { resolve } = require('path')
  172. const p = resolve(__dirname, 'utils/completion.sh')
  173. const d = (await readFile(p, 'utf8')).replace(/^#!.*?\n/, '')
  174. await new Promise((res, rej) => {
  175. let done = false
  176. process.stdout.write(d, () => {
  177. if (done)
  178. return
  179. done = true
  180. res()
  181. })
  182. process.stdout.on('error', er => {
  183. if (done)
  184. return
  185. done = true
  186. // Darwin is a pain sometimes.
  187. //
  188. // This is necessary because the "source" or "." program in
  189. // bash on OS X closes its file argument before reading
  190. // from it, meaning that you get exactly 1 write, which will
  191. // work most of the time, and will always raise an EPIPE.
  192. //
  193. // Really, one should not be tossing away EPIPE errors, or any
  194. // errors, so casually. But, without this, `. <(npm completion)`
  195. // can never ever work on OS X.
  196. if (er.errno === 'EPIPE')
  197. res()
  198. else
  199. rej(er)
  200. })
  201. })
  202. }
  203. const unescape = w => w.charAt(0) === '\'' ? w.replace(/^'|'$/g, '')
  204. : w.replace(/\\ /g, ' ')
  205. const escape = w => !/\s+/.test(w) ? w
  206. : '\'' + w + '\''
  207. // the current word has a dash. Return the config names,
  208. // with the same number of dashes as the current word has.
  209. const configCompl = opts => {
  210. const word = opts.word
  211. const split = word.match(/^(-+)((?:no-)*)(.*)$/)
  212. const dashes = split[1]
  213. const no = split[2]
  214. const flags = configNames.filter(isFlag)
  215. return allConfs.map(c => dashes + c)
  216. .concat(flags.map(f => dashes + (no || 'no-') + f))
  217. }
  218. // expand with the valid values of various config values.
  219. // not yet implemented.
  220. const configValueCompl = opts => []
  221. // check if the thing is a flag or not.
  222. const isFlag = word => {
  223. // shorthands never take args.
  224. const split = word.match(/^(-*)((?:no-)+)?(.*)$/)
  225. const no = split[2]
  226. const conf = split[3]
  227. const {type} = definitions[conf]
  228. return no ||
  229. type === Boolean ||
  230. (Array.isArray(type) && type.includes(Boolean)) ||
  231. shorthands[conf]
  232. }
  233. // complete against the npm commands
  234. // if they all resolve to the same thing, just return the thing it already is
  235. const cmdCompl = opts => {
  236. const matches = fullList.filter(c => c.startsWith(opts.partialWord))
  237. if (!matches.length)
  238. return matches
  239. const derefs = new Set([...matches.map(c => deref(c))])
  240. if (derefs.size === 1)
  241. return [...derefs]
  242. return fullList
  243. }
  244. module.exports = Completion