// An npm script as sh would read it, for the subset that runs without a shell: simple // commands (`NAME=value` prefixes, then words) in and-or lists joined by `&&`, `||`, `;` // and newlines. Quoting follows sh (POSIX 2.2): single quotes keep everything, double // quotes keep everything but `$`, backquote and the backslash escapes of `$`, backquote, // `"`, `\` and newline. `$NAME` and `${NAME}` expand from the script's environment, with // field splitting outside double quotes. Everything else sh has refuses, naming what it // met. Pure: the guest npm parses with it, and so does the Node-side test. /** One simple command: the assignments sh exports to it, then its words. */ export interface SimpleCommand { readonly assign: Readonly>; readonly argv: readonly [string, ...string[]]; /** The script line it starts on, as sh numbers its errors. */ readonly line: number; } /** Whether a step runs, by the status before it: `;` always, `&&` on 0, `||` on nonzero. */ export type When = "always" | "ok" | "failed"; export interface Step { readonly when: When; readonly command: SimpleCommand; } /** What sh has that a script here cannot use. */ export type Construct = | "a pipe" | "a background job" | "a redirect" | "a subshell" | "command substitution" | "arithmetic expansion" | "parameter expansion" | "a glob" | "tilde expansion" | "a reserved word" | "an assignment with no command" | "an unterminated quote" | "a syntax error"; export type Script = | { readonly kind: "commands"; readonly steps: readonly Step[] } /** `near` is the text sh would have run it from: "|", "$(", "*", "if", ... */ | { readonly kind: "refused"; readonly construct: Construct; readonly near: string }; class Refusal { constructor(readonly construct: Construct, readonly near: string) {} } const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const RESERVED = new Set(["if", "then", "else", "elif", "fi", "do", "done", "case", "esac", "while", "until", "for", "{", "}", "!"]); const IFS = /[ \t\n]+/; const GLOB = /[*?[]/; /** A word as it is read: its text so far, and what sh needs to know about how it was written. */ interface Word { text: string; /** Something, even "", was quoted or expanded into it: it is a word even if empty. */ started: boolean; /** No quoting or expansion at all: a reserved word only counts written bare. */ bare: boolean; /** An unquoted `NAME=` led it, in assignment position. */ assigns: string | null; } const newWord = (): Word => ({ text: "", started: false, bare: true, assigns: null }); export function parseScript(text: string, env: Readonly>): Script { try { return { kind: "commands", steps: parse(text, env) }; } catch (e) { if (e instanceof Refusal) return { kind: "refused", construct: e.construct, near: e.near }; throw e; } } function parse(s: string, env: Readonly>): Step[] { const steps: Step[] = []; let line = 1; let when: When = "always"; /** After `&&` or `||`: a command must follow, and newlines before it are not separators. */ let needCommand = false; let assign: Record = {}; let argv: string[] = []; let commandLine = 1; let word = newWord(); const endWord = (): void => { if (!word.started && word.text === "") { word = newWord(); return; } if (argv.length === 0 && word.assigns !== null) { assign[word.assigns] = word.text.slice(word.assigns.length + 1); } else { if (argv.length === 0 && word.bare && RESERVED.has(word.text)) throw new Refusal("a reserved word", word.text); argv.push(word.text); } word = newWord(); }; const empty = (): boolean => argv.length === 0 && Object.keys(assign).length === 0; /** Close the command before a separator, or at the end when `last`. */ const endCommand = (separator: string): void => { endWord(); if (argv.length === 0) { if (Object.keys(assign).length) throw new Refusal("an assignment with no command", Object.keys(assign).map((k) => `${k}=`).join(" ")); if (needCommand || separator === ";") throw new Refusal("a syntax error", separator || "end of script"); return; } steps.push({ when, command: { assign, argv: argv as [string, ...string[]], line: commandLine } }); assign = {}; argv = []; needCommand = false; }; const startsCommand = (): void => { if (empty() && !word.started && word.text === "") commandLine = line; }; /** Append an expansion's value: as is inside double quotes or an assignment, else split into fields. */ const expand = (value: string, quoted: boolean): void => { word.bare = false; if (quoted || word.assigns !== null) { word.text += value; word.started = true; return; } if (GLOB.test(value)) throw new Refusal("a glob", value); const fields = value.split(IFS); fields.forEach((field, k) => { if (k > 0) endWord(); word.bare = false; word.text += field; if (field) word.started = true; }); }; /** `$` at s[i]: the expansion it starts, and the index after it. */ const dollar = (i: number, quoted: boolean): number => { const next = s[i + 1]; if (next === "(") throw new Refusal(s[i + 2] === "(" ? "arithmetic expansion" : "command substitution", s[i + 2] === "(" ? "$((" : "$("); if (next === "{") { const close = s.indexOf("}", i + 2); const name = close === -1 ? "" : s.slice(i + 2, close); if (!NAME.test(name)) throw new Refusal("parameter expansion", close === -1 ? s.slice(i) : s.slice(i, close + 1)); expand(env[name] ?? "", quoted); return close + 1; } if (next !== undefined && /[A-Za-z_]/.test(next)) { let j = i + 1; while (j < s.length && /[A-Za-z0-9_]/.test(s[j]!)) j++; expand(env[s.slice(i + 1, j)] ?? "", quoted); return j; } if (next !== undefined && /[0-9@*#?$!-]/.test(next)) throw new Refusal("parameter expansion", `$${next}`); word.text += "$"; word.started = true; return i + 1; }; let i = 0; while (i < s.length) { const c = s[i]!; if (c === " " || c === "\t") { endWord(); i++; } else if (c === "\n") { if (needCommand && empty() && !word.started && word.text === "") { line++; i++; continue; } endCommand(""); when = "always"; line++; i++; } else if (c === "#" && !word.started && word.text === "") { while (i < s.length && s[i] !== "\n") i++; } else if (c === "\\") { startsCommand(); if (s[i + 1] === "\n") { line++; i += 2; continue; } word.bare = false; word.started = true; word.text += s[i + 1] ?? "\\"; i += 2; } else if (c === "'") { startsCommand(); const close = s.indexOf("'", i + 1); if (close === -1) throw new Refusal("an unterminated quote", "'"); const quoted = s.slice(i + 1, close); line += quoted.split("\n").length - 1; word.text += quoted; word.started = true; word.bare = false; i = close + 1; } else if (c === '"') { startsCommand(); word.started = true; word.bare = false; i++; for (;;) { const d = s[i]; if (d === undefined) throw new Refusal("an unterminated quote", '"'); if (d === '"') { i++; break; } if (d === "`") throw new Refusal("command substitution", "`"); if (d === "$") { i = dollar(i, true); continue; } if (d === "\\" && s[i + 1] !== undefined && '$`"\\\n'.includes(s[i + 1]!)) { if (s[i + 1] === "\n") line++; else word.text += s[i + 1]; i += 2; continue; } if (d === "\n") line++; word.text += d; i++; } } else if (c === "$") { startsCommand(); i = dollar(i, false); } else if (c === "`") { throw new Refusal("command substitution", "`"); } else if (c === "&" || c === "|") { if (s[i + 1] !== c) throw new Refusal(c === "|" ? "a pipe" : "a background job", c); const op = c === "&" ? "&&" : "||"; if (empty() && !word.started && word.text === "") throw new Refusal("a syntax error", op); endCommand(op); when = c === "&" ? "ok" : "failed"; needCommand = true; i += 2; } else if (c === ";") { if (s[i + 1] === ";") throw new Refusal("a syntax error", ";;"); if (empty() && !word.started && word.text === "") throw new Refusal("a syntax error", ";"); endCommand(";"); when = "always"; i++; } else if (c === "<" || c === ">") { throw new Refusal("a redirect", c); } else if (c === "(" || c === ")") { throw new Refusal("a subshell", c); } else if (c === "*" || c === "?" || c === "[") { throw new Refusal("a glob", c); } else if (c === "~" && !word.started && word.text === "") { throw new Refusal("tilde expansion", "~"); } else { startsCommand(); if (c === "=" && word.bare && word.assigns === null && argv.length === 0 && NAME.test(word.text)) word.assigns = word.text; word.text += c; word.started = true; i++; } } endCommand(""); return steps; } /** @npmcli/promise-spawn's escape.sh: an argument as npm appends it to a script for sh. */ export function shQuote(input: string): string { if (!input.length) return "''"; if (!/[\t\n\r "#$&'()*;<>?\\`|~]/.test(input)) return input; return `'${input.replace(/'/g, "'\\''")}'` .replace(/^(?:'')+(?!$)/, "") .replace(/\\'''/g, "\\'"); }