import { basename } from "node:path"; import type { ShellCommandUnit } from "./types"; type AnalyzeSource = ShellCommandUnit["source"]; type TokenizeResult = { tokens: string[]; redirections: string[]; malformed: boolean; }; const MAX_RECURSION_DEPTH = 6; const SHELL_WRAPPERS = new Set(["bash", "sh", "zsh", "dash", "ksh"]); function normalizeExecutable(command: string | undefined): string | undefined { if (!command) return undefined; return basename(command); } function hasBalancedOuterParens(text: string): boolean { const trimmed = text.trim(); if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return false; let depth = 0; let quote: "single" | "double" | undefined; for (let i = 0; i < trimmed.length; i += 1) { const char = trimmed[i]; if (quote === "single") { if (char === "'") quote = undefined; continue; } if (quote === "double") { if (char === "\\") { i += 1; continue; } if (char === "\"") quote = undefined; continue; } if (char === "'") { quote = "single"; continue; } if (char === "\"") { quote = "double"; continue; } if (char === "\\") { i += 1; continue; } if (char === "(") depth += 1; if (char === ")") depth -= 1; if (depth === 0 && i < trimmed.length - 1) return false; if (depth < 0) return false; } return depth === 0 && !quote; } function splitTopLevel(command: string): Array<{ text: string; viaPipe: boolean }> { const segments: Array<{ text: string; viaPipe: boolean }> = []; let quote: "single" | "double" | undefined; let parenDepth = 0; let current = ""; let viaPipe = false; const push = () => { const text = current.trim(); if (text) segments.push({ text, viaPipe }); current = ""; viaPipe = false; }; for (let i = 0; i < command.length; i += 1) { const char = command[i]; const next = command[i + 1]; if (quote === "single") { current += char; if (char === "'") quote = undefined; continue; } if (quote === "double") { current += char; if (char === "\\") { i += 1; if (i < command.length) current += command[i]; continue; } if (char === "\"") quote = undefined; continue; } if (char === "'") { quote = "single"; current += char; continue; } if (char === "\"") { quote = "double"; current += char; continue; } if (char === "\\") { current += char; i += 1; if (i < command.length) current += command[i]; continue; } if (char === "(") { parenDepth += 1; current += char; continue; } if (char === ")") { parenDepth = Math.max(0, parenDepth - 1); current += char; continue; } if (parenDepth === 0) { if (char === "&" && next === "&") { push(); i += 1; continue; } if (char === "|" && next === "|") { push(); i += 1; continue; } if (char === "|") { push(); viaPipe = true; continue; } if (char === ";" || char === "\n") { push(); continue; } } current += char; } push(); return segments; } function findMatchingParen(command: string, start: number): number { let depth = 0; let quote: "single" | "double" | undefined; for (let i = start; i < command.length; i += 1) { const char = command[i]; if (quote === "single") { if (char === "'") quote = undefined; continue; } if (quote === "double") { if (char === "\\") { i += 1; continue; } if (char === "\"") quote = undefined; continue; } if (char === "'") { quote = "single"; continue; } if (char === "\"") { quote = "double"; continue; } if (char === "\\") { i += 1; continue; } if (char === "(") depth += 1; if (char === ")") { depth -= 1; if (depth === 0) return i; } } return -1; } function extractCommandSubstitutions(command: string): string[] { const substitutions: string[] = []; let quote: "single" | "double" | undefined; for (let i = 0; i < command.length; i += 1) { const char = command[i]; const next = command[i + 1]; if (quote === "single") { if (char === "'") quote = undefined; continue; } if (quote === "double") { if (char === "\\") { i += 1; continue; } if (char === "\"") { quote = undefined; continue; } } else { if (char === "'") { quote = "single"; continue; } if (char === "\"") { quote = "double"; continue; } if (char === "\\") { i += 1; continue; } } if (char === "$" && next === "(") { const end = findMatchingParen(command, i + 1); if (end !== -1) { substitutions.push(command.slice(i + 2, end)); i = end; } continue; } if (char === "`") { let end = i + 1; let content = ""; for (; end < command.length; end += 1) { const inner = command[end]; if (inner === "\\") { content += inner; end += 1; if (end < command.length) content += command[end]; continue; } if (inner === "`") break; content += inner; } if (end < command.length) { substitutions.push(content); i = end; } } } return substitutions; } function tokenize(segment: string): TokenizeResult { const tokens: string[] = []; const rawTokens: string[] = []; let current = ""; let quote: "single" | "double" | undefined; let malformed = false; const push = () => { if (current.length > 0) { tokens.push(current); rawTokens.push(current); current = ""; } }; for (let i = 0; i < segment.length; i += 1) { const char = segment[i]; if (quote === "single") { if (char === "'") { quote = undefined; } else { current += char; } continue; } if (quote === "double") { if (char === "\\") { i += 1; if (i < segment.length) current += segment[i]; continue; } if (char === "\"") { quote = undefined; } else { current += char; } continue; } if (/\s/.test(char)) { push(); continue; } if (char === "'") { quote = "single"; continue; } if (char === "\"") { quote = "double"; continue; } if (char === "\\") { i += 1; if (i < segment.length) current += segment[i]; continue; } if (char === ">" || char === "<") { push(); let op = char; if (segment[i + 1] === char || segment[i + 1] === "&") { i += 1; op += segment[i]; } tokens.push(op); rawTokens.push(op); continue; } current += char; } push(); if (quote) malformed = true; const filteredTokens: string[] = []; const redirections: string[] = []; for (let i = 0; i < tokens.length; i += 1) { const token = tokens[i]; if (/^(?:\d*)?(?:>>?|<<|<&|>&|&>)$/.test(token) || /^(?:\d*)?(?:>>?|<<|<&|>&|&>).+/.test(token)) { const target = /^(?:\d*)?(?:>>?|<<|<&|>&|&>)$/.test(token) ? tokens[i + 1] : undefined; redirections.push(target ? `${token} ${target}` : token); if (target) i += 1; } else { filteredTokens.push(token); } } return { tokens: filteredTokens, redirections, malformed }; } function parseAssignment(token: string): [string, string] | undefined { const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(token); return match ? [match[1], match[2]] : undefined; } function makeUnit(segment: string, tokens: string[], redirections: string[], source: AnalyzeSource): ShellCommandUnit { const envAssignments: Record = {}; let commandIndex = 0; for (; commandIndex < tokens.length; commandIndex += 1) { const assignment = parseAssignment(tokens[commandIndex]); if (!assignment) break; envAssignments[assignment[0]] = assignment[1]; } const command = tokens[commandIndex]; const args = command ? tokens.slice(commandIndex + 1) : []; return { raw: segment.trim(), command, args, argsText: args.join(" "), ...(Object.keys(envAssignments).length > 0 ? { envAssignments } : {}), ...(redirections.length > 0 ? { redirections } : {}), source, }; } function quoteJoin(tokens: string[]): string { return tokens.map((token) => /\s/.test(token) ? JSON.stringify(token) : token).join(" "); } function shellWrapperCommandString(unit: ShellCommandUnit): string | undefined { const command = normalizeExecutable(unit.command); if (!command || !SHELL_WRAPPERS.has(command)) return undefined; for (let i = 0; i < unit.args.length; i += 1) { const arg = unit.args[i]; if (arg === "-c") return unit.args[i + 1]; if (/^-[A-Za-z]*c[A-Za-z]*$/.test(arg)) return unit.args[i + 1]; } return undefined; } function envNestedCommand(unit: ShellCommandUnit): string | undefined { if (normalizeExecutable(unit.command) !== "env") return undefined; let i = 0; for (; i < unit.args.length; i += 1) { const arg = unit.args[i]; if (arg === "--") { i += 1; break; } if (arg.startsWith("-") || parseAssignment(arg)) continue; break; } return i < unit.args.length ? quoteJoin(unit.args.slice(i)) : undefined; } function commandBuiltinNestedCommand(unit: ShellCommandUnit): string | undefined { if (normalizeExecutable(unit.command) !== "command") return undefined; let i = 0; for (; i < unit.args.length; i += 1) { const arg = unit.args[i]; if (arg === "--") { i += 1; break; } if (arg.startsWith("-")) continue; break; } return i < unit.args.length ? quoteJoin(unit.args.slice(i)) : undefined; } function sudoNestedCommand(unit: ShellCommandUnit): string | undefined { if (normalizeExecutable(unit.command) !== "sudo") return undefined; let i = 0; for (; i < unit.args.length; i += 1) { const arg = unit.args[i]; if (arg === "--") { i += 1; break; } if (arg.startsWith("-")) { // Options that consume a value. This is intentionally incomplete but // prevents common sudo flags from being mistaken for the nested command. if (["-u", "-g", "-h", "-p", "-C", "-T"].includes(arg)) i += 1; continue; } if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(arg)) continue; break; } return i < unit.args.length ? quoteJoin(unit.args.slice(i)) : undefined; } function xargsNestedCommand(unit: ShellCommandUnit): string | undefined { if (normalizeExecutable(unit.command) !== "xargs") return undefined; let i = 0; for (; i < unit.args.length; i += 1) { const arg = unit.args[i]; if (arg === "--") { i += 1; break; } if (arg.startsWith("-")) { if (["-I", "-E", "-n", "-L", "-P", "-s"].includes(arg)) i += 1; continue; } break; } return i < unit.args.length ? quoteJoin(unit.args.slice(i)) : undefined; } function findExecNestedCommand(unit: ShellCommandUnit): string | undefined { if (normalizeExecutable(unit.command) !== "find") return undefined; const execIndex = unit.args.findIndex((arg) => arg === "-exec" || arg === "-execdir"); if (execIndex === -1) return undefined; const nested: string[] = []; for (let i = execIndex + 1; i < unit.args.length; i += 1) { const arg = unit.args[i]; if (arg === ";" || arg === "+") break; nested.push(arg); } return nested.length > 0 ? quoteJoin(nested) : undefined; } function nestedCommandsFor(unit: ShellCommandUnit): string[] { return [ shellWrapperCommandString(unit), envNestedCommand(unit), commandBuiltinNestedCommand(unit), sudoNestedCommand(unit), xargsNestedCommand(unit), findExecNestedCommand(unit), ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); } function fallbackUnit(command: string): ShellCommandUnit { return { raw: command.trim(), args: [], argsText: command.trim(), source: "fallback", }; } function analyzeInternal(command: string, source: AnalyzeSource, depth: number): ShellCommandUnit[] { const trimmed = command.trim(); if (!trimmed) return []; if (depth > MAX_RECURSION_DEPTH) return [fallbackUnit(trimmed)]; const substitutionUnits = extractCommandSubstitutions(trimmed).flatMap((inner) => analyzeInternal(inner, "substitution", depth + 1), ); const segments = splitTopLevel(trimmed); if (segments.length === 0) return [fallbackUnit(trimmed), ...substitutionUnits]; const units: ShellCommandUnit[] = []; for (const segment of segments) { if (hasBalancedOuterParens(segment.text)) { units.push(...analyzeInternal(segment.text.slice(1, -1), "subshell", depth + 1)); continue; } const tokenized = tokenize(segment.text); if (tokenized.malformed) { units.push({ ...fallbackUnit(segment.text), source: "fallback" }); continue; } const unit = makeUnit(segment.text, tokenized.tokens, tokenized.redirections, segment.viaPipe ? "pipeline" : source); units.push(unit); for (const nested of nestedCommandsFor(unit)) { units.push(...analyzeInternal(nested, "wrapper", depth + 1)); } } return [...units, ...substitutionUnits]; } export function analyzeShellCommand(command: string): ShellCommandUnit[] { return analyzeInternal(command, "simple", 0); } export function executableBasename(command: string | undefined): string | undefined { return normalizeExecutable(command); }