/ utils / bash / prefix.ts
prefix.ts
  1  import { buildPrefix } from '../shell/specPrefix.js'
  2  import { splitCommand_DEPRECATED } from './commands.js'
  3  import { extractCommandArguments, parseCommand } from './parser.js'
  4  import { getCommandSpec } from './registry.js'
  5  
  6  const NUMERIC = /^\d+$/
  7  const ENV_VAR = /^[A-Za-z_][A-Za-z0-9_]*=/
  8  
  9  // Wrapper commands with complex option handling that can't be expressed in specs
 10  const WRAPPER_COMMANDS = new Set([
 11    'nice', // command position varies based on options
 12  ])
 13  
 14  const toArray = <T>(val: T | T[]): T[] => (Array.isArray(val) ? val : [val])
 15  
 16  // Check if args[0] matches a known subcommand (disambiguates wrapper commands
 17  // that also have subcommands, e.g. the git spec has isCommand args for aliases).
 18  function isKnownSubcommand(
 19    arg: string,
 20    spec: { subcommands?: { name: string | string[] }[] } | null,
 21  ): boolean {
 22    if (!spec?.subcommands?.length) return false
 23    return spec.subcommands.some(sub =>
 24      Array.isArray(sub.name) ? sub.name.includes(arg) : sub.name === arg,
 25    )
 26  }
 27  
 28  export async function getCommandPrefixStatic(
 29    command: string,
 30    recursionDepth = 0,
 31    wrapperCount = 0,
 32  ): Promise<{ commandPrefix: string | null } | null> {
 33    if (wrapperCount > 2 || recursionDepth > 10) return null
 34  
 35    const parsed = await parseCommand(command)
 36    if (!parsed) return null
 37    if (!parsed.commandNode) {
 38      return { commandPrefix: null }
 39    }
 40  
 41    const { envVars, commandNode } = parsed
 42    const cmdArgs = extractCommandArguments(commandNode)
 43  
 44    const [cmd, ...args] = cmdArgs
 45    if (!cmd) return { commandPrefix: null }
 46  
 47    // Check if this is a wrapper command by looking at its spec
 48    const spec = await getCommandSpec(cmd)
 49    // Check if this is a wrapper command
 50    let isWrapper =
 51      WRAPPER_COMMANDS.has(cmd) ||
 52      (spec?.args && toArray(spec.args).some(arg => arg?.isCommand))
 53  
 54    // Special case: if the command has subcommands and the first arg matches a subcommand,
 55    // treat it as a regular command, not a wrapper
 56    if (isWrapper && args[0] && isKnownSubcommand(args[0], spec)) {
 57      isWrapper = false
 58    }
 59  
 60    const prefix = isWrapper
 61      ? await handleWrapper(cmd, args, recursionDepth, wrapperCount)
 62      : await buildPrefix(cmd, args, spec)
 63  
 64    if (prefix === null && recursionDepth === 0 && isWrapper) {
 65      return null
 66    }
 67  
 68    const envPrefix = envVars.length ? `${envVars.join(' ')} ` : ''
 69    return { commandPrefix: prefix ? envPrefix + prefix : null }
 70  }
 71  
 72  async function handleWrapper(
 73    command: string,
 74    args: string[],
 75    recursionDepth: number,
 76    wrapperCount: number,
 77  ): Promise<string | null> {
 78    const spec = await getCommandSpec(command)
 79  
 80    if (spec?.args) {
 81      const commandArgIndex = toArray(spec.args).findIndex(arg => arg?.isCommand)
 82  
 83      if (commandArgIndex !== -1) {
 84        const parts = [command]
 85  
 86        for (let i = 0; i < args.length && i <= commandArgIndex; i++) {
 87          if (i === commandArgIndex) {
 88            const result = await getCommandPrefixStatic(
 89              args.slice(i).join(' '),
 90              recursionDepth + 1,
 91              wrapperCount + 1,
 92            )
 93            if (result?.commandPrefix) {
 94              parts.push(...result.commandPrefix.split(' '))
 95              return parts.join(' ')
 96            }
 97            break
 98          } else if (
 99            args[i] &&
100            !args[i]!.startsWith('-') &&
101            !ENV_VAR.test(args[i]!)
102          ) {
103            parts.push(args[i]!)
104          }
105        }
106      }
107    }
108  
109    const wrapped = args.find(
110      arg => !arg.startsWith('-') && !NUMERIC.test(arg) && !ENV_VAR.test(arg),
111    )
112    if (!wrapped) return command
113  
114    const result = await getCommandPrefixStatic(
115      args.slice(args.indexOf(wrapped)).join(' '),
116      recursionDepth + 1,
117      wrapperCount + 1,
118    )
119  
120    return !result?.commandPrefix ? null : `${command} ${result.commandPrefix}`
121  }
122  
123  /**
124   * Computes prefixes for a compound command (with && / || / ;).
125   * For single commands, returns a single-element array with the prefix.
126   *
127   * For compound commands, computes per-subcommand prefixes and collapses
128   * them: subcommands sharing a root (first word) are collapsed via
129   * word-aligned longest common prefix.
130   *
131   * @param excludeSubcommand — optional filter; return true for subcommands
132   *   that should be excluded from the prefix suggestion (e.g. read-only
133   *   commands that are already auto-allowed).
134   */
135  export async function getCompoundCommandPrefixesStatic(
136    command: string,
137    excludeSubcommand?: (subcommand: string) => boolean,
138  ): Promise<string[]> {
139    const subcommands = splitCommand_DEPRECATED(command)
140    if (subcommands.length <= 1) {
141      const result = await getCommandPrefixStatic(command)
142      return result?.commandPrefix ? [result.commandPrefix] : []
143    }
144  
145    const prefixes: string[] = []
146    for (const subcmd of subcommands) {
147      const trimmed = subcmd.trim()
148      if (excludeSubcommand?.(trimmed)) continue
149      const result = await getCommandPrefixStatic(trimmed)
150      if (result?.commandPrefix) {
151        prefixes.push(result.commandPrefix)
152      }
153    }
154  
155    if (prefixes.length === 0) return []
156  
157    // Group prefixes by their first word (root command)
158    const groups = new Map<string, string[]>()
159    for (const prefix of prefixes) {
160      const root = prefix.split(' ')[0]!
161      const group = groups.get(root)
162      if (group) {
163        group.push(prefix)
164      } else {
165        groups.set(root, [prefix])
166      }
167    }
168  
169    // Collapse each group via word-aligned LCP
170    const collapsed: string[] = []
171    for (const [, group] of groups) {
172      collapsed.push(longestCommonPrefix(group))
173    }
174    return collapsed
175  }
176  
177  /**
178   * Compute the longest common prefix of strings, aligned to word boundaries.
179   * e.g. ["git fetch", "git worktree"] → "git"
180   *      ["npm run test", "npm run lint"] → "npm run"
181   */
182  function longestCommonPrefix(strings: string[]): string {
183    if (strings.length === 0) return ''
184    if (strings.length === 1) return strings[0]!
185  
186    const first = strings[0]!
187    const words = first.split(' ')
188    let commonWords = words.length
189  
190    for (let i = 1; i < strings.length; i++) {
191      const otherWords = strings[i]!.split(' ')
192      let shared = 0
193      while (
194        shared < commonWords &&
195        shared < otherWords.length &&
196        words[shared] === otherWords[shared]
197      ) {
198        shared++
199      }
200      commonWords = shared
201    }
202  
203    return words.slice(0, Math.max(1, commonWords)).join(' ')
204  }