Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273// 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<Record<string, string>>; 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<Record<string, string | undefined>>): 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<Record<string, string | undefined>>): 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<string, string> = {}; 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, "\\'");}