/ utils / shell / specPrefix.ts
specPrefix.ts
  1  /**
  2   * Fig-spec-driven command prefix extraction.
  3   *
  4   * Given a command name + args array + its @withfig/autocomplete spec, walks
  5   * the spec to find how deep into the args a meaningful prefix extends.
  6   * `git -C /repo status --short` → `git status` (spec says -C takes a value,
  7   * skip it, find `status` as a known subcommand).
  8   *
  9   * Pure over (string, string[], CommandSpec) — no parser dependency. Extracted
 10   * from src/utils/bash/prefix.ts so PowerShell's extractor can reuse it;
 11   * external CLIs (git, npm, kubectl) are shell-agnostic.
 12   */
 13  
 14  import type { CommandSpec } from '../bash/registry.js'
 15  
 16  const URL_PROTOCOLS = ['http://', 'https://', 'ftp://']
 17  
 18  // Overrides for commands whose fig specs aren't available at runtime
 19  // (dynamic imports don't work in native/node builds). Without these,
 20  // calculateDepth falls back to 2, producing overly broad prefixes.
 21  export const DEPTH_RULES: Record<string, number> = {
 22    rg: 2, // pattern argument is required despite variadic paths
 23    'pre-commit': 2,
 24    // CLI tools with deep subcommand trees (e.g. gcloud scheduler jobs list)
 25    gcloud: 4,
 26    'gcloud compute': 6,
 27    'gcloud beta': 6,
 28    aws: 4,
 29    az: 4,
 30    kubectl: 3,
 31    docker: 3,
 32    dotnet: 3,
 33    'git push': 2,
 34  }
 35  
 36  const toArray = <T>(val: T | T[]): T[] => (Array.isArray(val) ? val : [val])
 37  
 38  // Check if an argument matches a known subcommand (case-insensitive: PS
 39  // callers pass original-cased args; fig spec names are lowercase)
 40  function isKnownSubcommand(arg: string, spec: CommandSpec | null): boolean {
 41    if (!spec?.subcommands?.length) return false
 42    const argLower = arg.toLowerCase()
 43    return spec.subcommands.some(sub =>
 44      Array.isArray(sub.name)
 45        ? sub.name.some(n => n.toLowerCase() === argLower)
 46        : sub.name.toLowerCase() === argLower,
 47    )
 48  }
 49  
 50  // Check if a flag takes an argument based on spec, or use heuristic
 51  function flagTakesArg(
 52    flag: string,
 53    nextArg: string | undefined,
 54    spec: CommandSpec | null,
 55  ): boolean {
 56    // Check if flag is in spec.options
 57    if (spec?.options) {
 58      const option = spec.options.find(opt =>
 59        Array.isArray(opt.name) ? opt.name.includes(flag) : opt.name === flag,
 60      )
 61      if (option) return !!option.args
 62    }
 63    // Heuristic: if next arg isn't a flag and isn't a known subcommand, assume it's a flag value
 64    if (spec?.subcommands?.length && nextArg && !nextArg.startsWith('-')) {
 65      return !isKnownSubcommand(nextArg, spec)
 66    }
 67    return false
 68  }
 69  
 70  // Find the first subcommand by skipping flags and their values
 71  function findFirstSubcommand(
 72    args: string[],
 73    spec: CommandSpec | null,
 74  ): string | undefined {
 75    for (let i = 0; i < args.length; i++) {
 76      const arg = args[i]
 77      if (!arg) continue
 78      if (arg.startsWith('-')) {
 79        if (flagTakesArg(arg, args[i + 1], spec)) i++
 80        continue
 81      }
 82      if (!spec?.subcommands?.length) return arg
 83      if (isKnownSubcommand(arg, spec)) return arg
 84    }
 85    return undefined
 86  }
 87  
 88  export async function buildPrefix(
 89    command: string,
 90    args: string[],
 91    spec: CommandSpec | null,
 92  ): Promise<string> {
 93    const maxDepth = await calculateDepth(command, args, spec)
 94    const parts = [command]
 95    const hasSubcommands = !!spec?.subcommands?.length
 96    let foundSubcommand = false
 97  
 98    for (let i = 0; i < args.length; i++) {
 99      const arg = args[i]
100      if (!arg || parts.length >= maxDepth) break
101  
102      if (arg.startsWith('-')) {
103        // Special case: python -c should stop after -c
104        if (arg === '-c' && ['python', 'python3'].includes(command.toLowerCase()))
105          break
106  
107        // Check for isCommand/isModule flags that should be included in prefix
108        if (spec?.options) {
109          const option = spec.options.find(opt =>
110            Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg,
111          )
112          if (
113            option?.args &&
114            toArray(option.args).some(a => a?.isCommand || a?.isModule)
115          ) {
116            parts.push(arg)
117            continue
118          }
119        }
120  
121        // For commands with subcommands, skip global flags to find the subcommand
122        if (hasSubcommands && !foundSubcommand) {
123          if (flagTakesArg(arg, args[i + 1], spec)) i++
124          continue
125        }
126        break // Stop at flags (original behavior)
127      }
128  
129      if (await shouldStopAtArg(arg, args.slice(0, i), spec)) break
130      if (hasSubcommands && !foundSubcommand) {
131        foundSubcommand = isKnownSubcommand(arg, spec)
132      }
133      parts.push(arg)
134    }
135  
136    return parts.join(' ')
137  }
138  
139  async function calculateDepth(
140    command: string,
141    args: string[],
142    spec: CommandSpec | null,
143  ): Promise<number> {
144    // Find first subcommand by skipping flags and their values
145    const firstSubcommand = findFirstSubcommand(args, spec)
146    const commandLower = command.toLowerCase()
147    const key = firstSubcommand
148      ? `${commandLower} ${firstSubcommand.toLowerCase()}`
149      : commandLower
150    if (DEPTH_RULES[key]) return DEPTH_RULES[key]
151    if (DEPTH_RULES[commandLower]) return DEPTH_RULES[commandLower]
152    if (!spec) return 2
153  
154    if (spec.options && args.some(arg => arg?.startsWith('-'))) {
155      for (const arg of args) {
156        if (!arg?.startsWith('-')) continue
157        const option = spec.options.find(opt =>
158          Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg,
159        )
160        if (
161          option?.args &&
162          toArray(option.args).some(arg => arg?.isCommand || arg?.isModule)
163        )
164          return 3
165      }
166    }
167  
168    // Find subcommand spec using the already-found firstSubcommand
169    if (firstSubcommand && spec.subcommands?.length) {
170      const firstSubLower = firstSubcommand.toLowerCase()
171      const subcommand = spec.subcommands.find(sub =>
172        Array.isArray(sub.name)
173          ? sub.name.some(n => n.toLowerCase() === firstSubLower)
174          : sub.name.toLowerCase() === firstSubLower,
175      )
176      if (subcommand) {
177        if (subcommand.args) {
178          const subArgs = toArray(subcommand.args)
179          if (subArgs.some(arg => arg?.isCommand)) return 3
180          if (subArgs.some(arg => arg?.isVariadic)) return 2
181        }
182        if (subcommand.subcommands?.length) return 4
183        // Leaf subcommand with NO args declared (git show, git log, git tag):
184        // the 3rd word is transient (SHA, ref, tag name) → dead over-specific
185        // rule like PowerShell(git show 81210f8:*). NOT the isOptional case —
186        // `git fetch` declares optional remote/branch and `git fetch origin`
187        // is tested (bash/prefix.test.ts:912) as intentional remote scoping.
188        if (!subcommand.args) return 2
189        return 3
190      }
191    }
192  
193    if (spec.args) {
194      const argsArray = toArray(spec.args)
195  
196      if (argsArray.some(arg => arg?.isCommand)) {
197        return !Array.isArray(spec.args) && spec.args.isCommand
198          ? 2
199          : Math.min(2 + argsArray.findIndex(arg => arg?.isCommand), 3)
200      }
201  
202      if (!spec.subcommands?.length) {
203        if (argsArray.some(arg => arg?.isVariadic)) return 1
204        if (argsArray[0] && !argsArray[0].isOptional) return 2
205      }
206    }
207  
208    return spec.args && toArray(spec.args).some(arg => arg?.isDangerous) ? 3 : 2
209  }
210  
211  async function shouldStopAtArg(
212    arg: string,
213    args: string[],
214    spec: CommandSpec | null,
215  ): Promise<boolean> {
216    if (arg.startsWith('-')) return true
217  
218    const dotIndex = arg.lastIndexOf('.')
219    const hasExtension =
220      dotIndex > 0 &&
221      dotIndex < arg.length - 1 &&
222      !arg.substring(dotIndex + 1).includes(':')
223  
224    const hasFile = arg.includes('/') || hasExtension
225    const hasUrl = URL_PROTOCOLS.some(proto => arg.startsWith(proto))
226  
227    if (!hasFile && !hasUrl) return false
228  
229    // Check if we're after a -m flag for python modules
230    if (spec?.options && args.length > 0 && args[args.length - 1] === '-m') {
231      const option = spec.options.find(opt =>
232        Array.isArray(opt.name) ? opt.name.includes('-m') : opt.name === '-m',
233      )
234      if (option?.args && toArray(option.args).some(arg => arg?.isModule)) {
235        return false // Don't stop at module names
236      }
237    }
238  
239    // For actual files/URLs, always stop regardless of context
240    return true
241  }