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