πŸ“„ File detail

utils/shell/specPrefix.ts

🧩 .tsπŸ“ 242 linesπŸ’Ύ 7,905 bytesπŸ“ text
← Back to All Files

🎯 Use case

This file lives under β€œutils/”, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, …). On the API surface it exposes DEPTH_RULES and buildPrefix β€” mainly functions, hooks, or classes. It composes internal code from bash (relative imports). What the file header says: Fig-spec-driven command prefix extraction. Given a command name + args array + its @withfig/autocomplete spec, walks the spec to find how deep into the args a meaningful prefix extends. `git -C /repo status --short` β†’ `git status` (spec says -C takes a value, skip it, find `statu.

Generated from folder role, exports, dependency roots, and inline comments β€” not hand-reviewed for every path.

🧠 Inline summary

Fig-spec-driven command prefix extraction. Given a command name + args array + its @withfig/autocomplete spec, walks the spec to find how deep into the args a meaningful prefix extends. `git -C /repo status --short` β†’ `git status` (spec says -C takes a value, skip it, find `status` as a known subcommand). Pure over (string, string[], CommandSpec) β€” no parser dependency. Extracted from src/utils/bash/prefix.ts so PowerShell's extractor can reuse it; external CLIs (git, npm, kubectl) are shell-agnostic.

πŸ“€ Exports (heuristic)

  • DEPTH_RULES
  • buildPrefix

πŸ–₯️ Source preview

/**
 * Fig-spec-driven command prefix extraction.
 *
 * Given a command name + args array + its @withfig/autocomplete spec, walks
 * the spec to find how deep into the args a meaningful prefix extends.
 * `git -C /repo status --short` β†’ `git status` (spec says -C takes a value,
 * skip it, find `status` as a known subcommand).
 *
 * Pure over (string, string[], CommandSpec) β€” no parser dependency. Extracted
 * from src/utils/bash/prefix.ts so PowerShell's extractor can reuse it;
 * external CLIs (git, npm, kubectl) are shell-agnostic.
 */

import type { CommandSpec } from '../bash/registry.js'

const URL_PROTOCOLS = ['http://', 'https://', 'ftp://']

// Overrides for commands whose fig specs aren't available at runtime
// (dynamic imports don't work in native/node builds). Without these,
// calculateDepth falls back to 2, producing overly broad prefixes.
export const DEPTH_RULES: Record<string, number> = {
  rg: 2, // pattern argument is required despite variadic paths
  'pre-commit': 2,
  // CLI tools with deep subcommand trees (e.g. gcloud scheduler jobs list)
  gcloud: 4,
  'gcloud compute': 6,
  'gcloud beta': 6,
  aws: 4,
  az: 4,
  kubectl: 3,
  docker: 3,
  dotnet: 3,
  'git push': 2,
}

const toArray = <T>(val: T | T[]): T[] => (Array.isArray(val) ? val : [val])

// Check if an argument matches a known subcommand (case-insensitive: PS
// callers pass original-cased args; fig spec names are lowercase)
function isKnownSubcommand(arg: string, spec: CommandSpec | null): boolean {
  if (!spec?.subcommands?.length) return false
  const argLower = arg.toLowerCase()
  return spec.subcommands.some(sub =>
    Array.isArray(sub.name)
      ? sub.name.some(n => n.toLowerCase() === argLower)
      : sub.name.toLowerCase() === argLower,
  )
}

// Check if a flag takes an argument based on spec, or use heuristic
function flagTakesArg(
  flag: string,
  nextArg: string | undefined,
  spec: CommandSpec | null,
): boolean {
  // Check if flag is in spec.options
  if (spec?.options) {
    const option = spec.options.find(opt =>
      Array.isArray(opt.name) ? opt.name.includes(flag) : opt.name === flag,
    )
    if (option) return !!option.args
  }
  // Heuristic: if next arg isn't a flag and isn't a known subcommand, assume it's a flag value
  if (spec?.subcommands?.length && nextArg && !nextArg.startsWith('-')) {
    return !isKnownSubcommand(nextArg, spec)
  }
  return false
}

// Find the first subcommand by skipping flags and their values
function findFirstSubcommand(
  args: string[],
  spec: CommandSpec | null,
): string | undefined {
  for (let i = 0; i < args.length; i++) {
    const arg = args[i]
    if (!arg) continue
    if (arg.startsWith('-')) {
      if (flagTakesArg(arg, args[i + 1], spec)) i++
      continue
    }
    if (!spec?.subcommands?.length) return arg
    if (isKnownSubcommand(arg, spec)) return arg
  }
  return undefined
}

export async function buildPrefix(
  command: string,
  args: string[],
  spec: CommandSpec | null,
): Promise<string> {
  const maxDepth = await calculateDepth(command, args, spec)
  const parts = [command]
  const hasSubcommands = !!spec?.subcommands?.length
  let foundSubcommand = false

  for (let i = 0; i < args.length; i++) {
    const arg = args[i]
    if (!arg || parts.length >= maxDepth) break

    if (arg.startsWith('-')) {
      // Special case: python -c should stop after -c
      if (arg === '-c' && ['python', 'python3'].includes(command.toLowerCase()))
        break

      // Check for isCommand/isModule flags that should be included in prefix
      if (spec?.options) {
        const option = spec.options.find(opt =>
          Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg,
        )
        if (
          option?.args &&
          toArray(option.args).some(a => a?.isCommand || a?.isModule)
        ) {
          parts.push(arg)
          continue
        }
      }

      // For commands with subcommands, skip global flags to find the subcommand
      if (hasSubcommands && !foundSubcommand) {
        if (flagTakesArg(arg, args[i + 1], spec)) i++
        continue
      }
      break // Stop at flags (original behavior)
    }

    if (await shouldStopAtArg(arg, args.slice(0, i), spec)) break
    if (hasSubcommands && !foundSubcommand) {
      foundSubcommand = isKnownSubcommand(arg, spec)
    }
    parts.push(arg)
  }

  return parts.join(' ')
}

async function calculateDepth(
  command: string,
  args: string[],
  spec: CommandSpec | null,
): Promise<number> {
  // Find first subcommand by skipping flags and their values
  const firstSubcommand = findFirstSubcommand(args, spec)
  const commandLower = command.toLowerCase()
  const key = firstSubcommand
    ? `${commandLower} ${firstSubcommand.toLowerCase()}`
    : commandLower
  if (DEPTH_RULES[key]) return DEPTH_RULES[key]
  if (DEPTH_RULES[commandLower]) return DEPTH_RULES[commandLower]
  if (!spec) return 2

  if (spec.options && args.some(arg => arg?.startsWith('-'))) {
    for (const arg of args) {
      if (!arg?.startsWith('-')) continue
      const option = spec.options.find(opt =>
        Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg,
      )
      if (
        option?.args &&
        toArray(option.args).some(arg => arg?.isCommand || arg?.isModule)
      )
        return 3
    }
  }

  // Find subcommand spec using the already-found firstSubcommand
  if (firstSubcommand && spec.subcommands?.length) {
    const firstSubLower = firstSubcommand.toLowerCase()
    const subcommand = spec.subcommands.find(sub =>
      Array.isArray(sub.name)
        ? sub.name.some(n => n.toLowerCase() === firstSubLower)
        : sub.name.toLowerCase() === firstSubLower,
    )
    if (subcommand) {
      if (subcommand.args) {
        const subArgs = toArray(subcommand.args)
        if (subArgs.some(arg => arg?.isCommand)) return 3
        if (subArgs.some(arg => arg?.isVariadic)) return 2
      }
      if (subcommand.subcommands?.length) return 4
      // Leaf subcommand with NO args declared (git show, git log, git tag):
      // the 3rd word is transient (SHA, ref, tag name) β†’ dead over-specific
      // rule like PowerShell(git show 81210f8:*). NOT the isOptional case β€”
      // `git fetch` declares optional remote/branch and `git fetch origin`
      // is tested (bash/prefix.test.ts:912) as intentional remote scoping.
      if (!subcommand.args) return 2
      return 3
    }
  }

  if (spec.args) {
    const argsArray = toArray(spec.args)

    if (argsArray.some(arg => arg?.isCommand)) {
      return !Array.isArray(spec.args) && spec.args.isCommand
        ? 2
        : Math.min(2 + argsArray.findIndex(arg => arg?.isCommand), 3)
    }

    if (!spec.subcommands?.length) {
      if (argsArray.some(arg => arg?.isVariadic)) return 1
      if (argsArray[0] && !argsArray[0].isOptional) return 2
    }
  }

  return spec.args && toArray(spec.args).some(arg => arg?.isDangerous) ? 3 : 2
}

async function shouldStopAtArg(
  arg: string,
  args: string[],
  spec: CommandSpec | null,
): Promise<boolean> {
  if (arg.startsWith('-')) return true

  const dotIndex = arg.lastIndexOf('.')
  const hasExtension =
    dotIndex > 0 &&
    dotIndex < arg.length - 1 &&
    !arg.substring(dotIndex + 1).includes(':')

  const hasFile = arg.includes('/') || hasExtension
  const hasUrl = URL_PROTOCOLS.some(proto => arg.startsWith(proto))

  if (!hasFile && !hasUrl) return false

  // Check if we're after a -m flag for python modules
  if (spec?.options && args.length > 0 && args[args.length - 1] === '-m') {
    const option = spec.options.find(opt =>
      Array.isArray(opt.name) ? opt.name.includes('-m') : opt.name === '-m',
    )
    if (option?.args && toArray(option.args).some(arg => arg?.isModule)) {
      return false // Don't stop at module names
    }
  }

  // For actual files/URLs, always stop regardless of context
  return true
}