Roo-Code/src/shared/parse-command.ts
2025-11-10 17:01:50 -08:00

223 lines
6.7 KiB
TypeScript

import { parse } from "shell-quote"
export type ShellToken = string | { op: string } | { command: string }
/**
* Split a command string into individual sub-commands by
* chaining operators (&&, ||, ;, |, or &) and newlines.
*
* Uses shell-quote to properly handle:
* - Quoted strings (preserves quotes)
* - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
* - PowerShell redirections (2>&1)
* - Chain operators (&&, ||, ;, |, &)
* - Newlines as command separators
*/
export function parseCommand(command: string): string[] {
if (!command?.trim()) {
return []
}
// Split by newlines first (handle different line ending formats)
// This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac)
const lines = command.split(/\r\n|\r|\n/)
const allCommands: string[] = []
for (const line of lines) {
// Skip empty lines
if (!line.trim()) {
continue
}
// Process each line through the existing parsing logic
const lineCommands = parseCommandLine(line)
allCommands.push(...lineCommands)
}
return allCommands
}
/**
* Parse a single line of commands.
*/
function parseCommandLine(command: string): string[] {
if (!command?.trim()) return []
// Storage for replaced content
const redirections: string[] = []
const subshells: string[] = []
const quotes: string[] = []
const arrayIndexing: string[] = []
const arithmeticExpressions: string[] = []
const variables: string[] = []
const parameterExpansions: string[] = []
// First handle PowerShell redirections by temporarily replacing them
let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
redirections.push(match)
return `__REDIR_${redirections.length - 1}__`
})
// Handle arithmetic expressions: $((...)) pattern
// Match the entire arithmetic expression including nested parentheses
processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => {
arithmeticExpressions.push(match)
return `__ARITH_${arithmeticExpressions.length - 1}__`
})
// Handle $[...] arithmetic expressions (alternative syntax)
processedCommand = processedCommand.replace(/\$\[[^\]]*\]/g, (match) => {
arithmeticExpressions.push(match)
return `__ARITH_${arithmeticExpressions.length - 1}__`
})
// Handle parameter expansions: ${...} patterns (including array indexing)
// This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc.
processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => {
parameterExpansions.push(match)
return `__PARAM_${parameterExpansions.length - 1}__`
})
// Handle process substitutions: <(...) and >(...)
processedCommand = processedCommand.replace(/[<>]\(([^)]+)\)/g, (_, inner) => {
subshells.push(inner.trim())
return `__SUBSH_${subshells.length - 1}__`
})
// Handle simple variable references: $varname pattern
// This prevents shell-quote from splitting $count into separate tokens
processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => {
variables.push(match)
return `__VAR_${variables.length - 1}__`
})
// Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9
processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => {
variables.push(match)
return `__VAR_${variables.length - 1}__`
})
// Then handle subshell commands $() and back-ticks
processedCommand = processedCommand
.replace(/\$\((.*?)\)/g, (_, inner) => {
subshells.push(inner.trim())
return `__SUBSH_${subshells.length - 1}__`
})
.replace(/`(.*?)`/g, (_, inner) => {
subshells.push(inner.trim())
return `__SUBSH_${subshells.length - 1}__`
})
// Then handle quoted strings
processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => {
quotes.push(match)
return `__QUOTE_${quotes.length - 1}__`
})
let tokens: ShellToken[]
try {
tokens = parse(processedCommand) as ShellToken[]
} catch (error: any) {
// If shell-quote fails to parse, fall back to simple splitting
console.warn("shell-quote parse error:", error.message, "for command:", processedCommand)
// Simple fallback: split by common operators
const fallbackCommands = processedCommand
.split(/(?:&&|\|\||;|\||&)/)
.map((cmd) => cmd.trim())
.filter((cmd) => cmd.length > 0)
// Restore all placeholders for each command
return fallbackCommands.map((cmd) =>
restorePlaceholders(
cmd,
quotes,
redirections,
arrayIndexing,
arithmeticExpressions,
parameterExpansions,
variables,
subshells,
),
)
}
const commands: string[] = []
let currentCommand: string[] = []
for (const token of tokens) {
if (typeof token === "object" && "op" in token) {
// Chain operator - split command
if (["&&", "||", ";", "|", "&"].includes(token.op)) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
} else {
// Other operators (>) are part of the command
currentCommand.push(token.op)
}
} else if (typeof token === "string") {
// Check if it's a subshell placeholder
const subshellMatch = token.match(/__SUBSH_(\d+)__/)
if (subshellMatch) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
commands.push(subshells[parseInt(subshellMatch[1])])
} else {
currentCommand.push(token)
}
}
}
// Add any remaining command
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
}
// Restore quotes and redirections
return commands.map((cmd) =>
restorePlaceholders(
cmd,
quotes,
redirections,
arrayIndexing,
arithmeticExpressions,
parameterExpansions,
variables,
subshells,
),
)
}
/**
* Helper function to restore placeholders in a command string.
*/
function restorePlaceholders(
command: string,
quotes: string[],
redirections: string[],
arrayIndexing: string[],
arithmeticExpressions: string[],
parameterExpansions: string[],
variables: string[],
subshells: string[],
): string {
let result = command
// Restore quotes
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
// Restore redirections
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
// Restore array indexing expressions
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
// Restore arithmetic expressions
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
// Restore parameter expansions
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
// Restore variable references
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
result = result.replace(/__SUBSH_(\d+)__/g, (_, i) => subshells[parseInt(i)])
return result
}