Team Ai
Datasetpublic

codekingpro/portable-devtools

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