112 lines
3.0 KiB
TypeScript
112 lines
3.0 KiB
TypeScript
/**
|
|
* Quote-aware composite-command splitter.
|
|
*
|
|
* Splits a shell command string on top-level `&&`, `||`, or `;` separators
|
|
* (i.e. those NOT inside single quotes, double quotes, backtick subshells,
|
|
* or `$(...)` subshells) and returns the LAST significant segment (trimmed).
|
|
*
|
|
* - No separator found → returns the input unchanged.
|
|
* - Last segment is empty (e.g. trailing `&&`) → falls back to the previous non-empty one.
|
|
* - O(n) char-by-char scan; zero RegExp over the full input (anti-ReDoS).
|
|
*/
|
|
export function lastCommandSegment(command: string): string {
|
|
if (!command) return command;
|
|
|
|
const segments: string[] = [];
|
|
let current = 0; // start index of the current segment
|
|
let depth = 0; // nesting depth for $(...) / (...)
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
let inBacktick = false;
|
|
|
|
const push = (end: number): void => {
|
|
segments.push(command.slice(current, end));
|
|
};
|
|
|
|
for (let i = 0; i < command.length; i++) {
|
|
const ch = command[i];
|
|
|
|
// ── quote / subshell state tracking ──────────────────────────────────
|
|
if (inSingle) {
|
|
if (ch === "'") inSingle = false;
|
|
continue;
|
|
}
|
|
if (inBacktick) {
|
|
if (ch === "`") inBacktick = false;
|
|
continue;
|
|
}
|
|
if (inDouble) {
|
|
if (ch === '"') inDouble = false;
|
|
// $( inside double-quotes still opens a subshell
|
|
if (ch === "$" && command[i + 1] === "(") {
|
|
depth++;
|
|
i++; // skip the '('
|
|
}
|
|
continue;
|
|
}
|
|
if (depth > 0) {
|
|
if (ch === "(") depth++;
|
|
else if (ch === ")") depth--;
|
|
continue;
|
|
}
|
|
|
|
// ── open new quote / subshell context ────────────────────────────────
|
|
if (ch === "'") {
|
|
inSingle = true;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = true;
|
|
continue;
|
|
}
|
|
if (ch === "`") {
|
|
inBacktick = true;
|
|
continue;
|
|
}
|
|
if (ch === "$" && command[i + 1] === "(") {
|
|
depth++;
|
|
i++; // skip the '('
|
|
continue;
|
|
}
|
|
if (ch === "(") {
|
|
depth++;
|
|
continue;
|
|
}
|
|
|
|
// ── top-level separator detection ─────────────────────────────────────
|
|
if (ch === "&" && command[i + 1] === "&") {
|
|
push(i);
|
|
i += 1; // skip second '&'
|
|
current = i + 1;
|
|
continue;
|
|
}
|
|
if (ch === "|" && command[i + 1] === "|") {
|
|
push(i);
|
|
i += 1; // skip second '|'
|
|
current = i + 1;
|
|
continue;
|
|
}
|
|
if (ch === ";") {
|
|
push(i);
|
|
current = i + 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// push the remainder
|
|
push(command.length);
|
|
|
|
if (segments.length === 1) {
|
|
// no top-level separator found → return unchanged
|
|
return command;
|
|
}
|
|
|
|
// find the last non-empty (after trim) segment
|
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
const trimmed = segments[i].trim();
|
|
if (trimmed) return trimmed;
|
|
}
|
|
|
|
return command;
|
|
}
|