import { ArgSpec, Condition, PATH_TOKEN_TYPES, Recipe, TokenDef, TokenDefComposite, } from "./commandBuilderTypes"; const tokenRegex = /\{\{([^}]+)\}\}/g; /** * Replace every `{{name}}` in `text` with its value ("" when missing). * * Deliberately not a templating library: recipe text is full of backslashes * that must survive verbatim (ffmpeg filter escapes like `mod(n\,90)`, Windows * paths), and general-purpose template engines treat `\` as an escape. */ export function interpolate( text: string, values: Record, ): string { return text.replace(tokenRegex, (_, name: string) => values[name] ?? ""); } /** Names of every token referenced in a piece of text, in order of appearance. */ export function tokensInArg(arg: string): string[] { return [...arg.matchAll(tokenRegex)].map((m) => m[1]); } /** Split an arg template into shell words on whitespace. */ export function splitWords(arg: string): string[] { return arg.split(/\s+/).filter((w) => w !== ""); } /** Every token an arg spec references, in its text and in its `when`s. */ function tokensInSpec(spec: ArgSpec): string[] { if (typeof spec === "string") return tokensInArg(spec); return [ ...Object.keys(spec.when ?? {}), ...tokensInArg(spec.arg ?? ""), ...(spec.parts ?? []).flatMap(tokensInSpec), ]; } /** * Every token referenced across a recipe's args (including `when` conditions), * de-duplicated, in order of first appearance. */ export function tokensInUse(recipe: Recipe): string[] { return [...new Set(recipe.args.flatMap(tokensInSpec))]; } /** The minimal shape of a stored token value that assembly needs. */ export interface TokenValueLike { value: unknown; enabled: boolean; } /** Which family of shell the assembled command will be pasted into. */ export type ShellFamily = "powershell" | "posix"; export interface AssembleOptions { /** * Transform a path value before quoting (e.g. absolute→relative). Injected so * the core stays free of React/cwd. Defaults to identity. */ pathTransform?: (path: string) => string; /** * Shell the command will run in, so words are quoted correctly. Defaults to * "posix" until the real shell is known. */ shell?: ShellFamily; } // Characters safe to leave unquoted, per shell. Anything else (whitespace, // redirection <>, pipes, &, ;, quotes, parentheses, …) forces quoting. // PowerShell passes backslash paths through literally; POSIX shells treat `\` // as an escape. A comma is safe in POSIX but builds an array in PowerShell. const SAFE_UNQUOTED: Record = { posix: /^[A-Za-z0-9._+:/=@%,-]+$/, powershell: /^[A-Za-z0-9._+:/=@%\\-]+$/, }; /** Words that are made of safe characters but still misparse unquoted. */ function needsQuotingAnyway(word: string, shell: ShellFamily): boolean { if (shell === "powershell") { // `@x` is splatting (the word disappears); `-foo.bar` is split in two at // the dot by Windows PowerShell 5.1. return word.startsWith("@") || (/^-[^-]/.test(word) && word.includes(".")); } // zsh expands a leading `=` (`=ls` → /bin/ls). return word.startsWith("="); } /** * Windows PowerShell 5.1 (what the app spawns) rebuilds a native command line * by wrapping arguments that contain whitespace in double quotes, without * escaping anything inside. The program then parses that line with the C * runtime rules, so an embedded `"` (and backslashes before one, or trailing * backslashes inside the added quotes) must be escaped C-runtime style or the * argument is split or merged with its neighbour. PowerShell 7.3+ passes * arguments correctly and would not need this. */ function escapeForPowerShellNative(word: string): string { let out = word.replace(/(\\*)"/g, (_, slashes: string) => `${slashes}${slashes}\\"`); if (/\s/.test(word)) { out = out.replace(/(\\+)$/, "$1$1"); } return out; } /** * Quote one shell word so the shell passes it through literally. Uses single * quotes — literal in both PowerShell and POSIX shells (no $, backtick, or glob * expansion) — differing only in how an embedded single quote is escaped. * Empty and already-safe words are returned unquoted for clean commands. */ export function shellQuote( value: string, shell: ShellFamily = "posix", ): string { if ( value === "" || (SAFE_UNQUOTED[shell].test(value) && !needsQuotingAnyway(value, shell)) ) { return value; } if (shell === "powershell") { // PowerShell: double the embedded single quote. return `'${escapeForPowerShellNative(value).replace(/'/g, "''")}'`; } // POSIX: close the quote, emit an escaped quote, reopen. return `'${value.replace(/'/g, `'\\''`)}'`; } /** * What a character means to the shell, finishing "has a X, which …", for * explaining why a word is quoted. */ const characterMeanings = (shell: ShellFamily): [RegExp, string][] => { const shellName = shell === "powershell" ? "PowerShell" : "the shell"; return [ [/[$]/, `${shellName} reads as the start of a variable`], [ /[&]/, shell === "powershell" ? "PowerShell doesn't allow in plain text" : 'the shell reads as "run this in the background"', ], [/[;]/, `${shellName} reads as the end of a command`], [/[|]/, `${shellName} reads as "send the output to another command"`], [/[<>]/, `${shellName} reads as "write to (or read from) a file"`], [/[*?[\]]/, "some shells read as a wildcard matching file names"], [/[#]/, `${shellName} can read as the start of a comment`], [/[~]/, "some shells read as your home folder"], [/['"`]/, "would otherwise start or end a quote"], ]; }; /** * Why `shellQuote` wraps a word in quotes, in plain words for a beginner * ("has a comma, which PowerShell reads as a list"), or undefined if it * doesn't. */ export function quoteReason( word: string, shell: ShellFamily = "posix", ): string | undefined { if (shellQuote(word, shell) === word) return undefined; if (/\s/.test(word)) { return "has a space, which would otherwise split it into separate pieces"; } if (shell === "powershell") { if (word.startsWith("@")) { return "starts with @, which means something special to PowerShell"; } if (word.includes(",")) { return "has a comma, which PowerShell reads as a list"; } if (needsQuotingAnyway(word, shell)) { return "starts with a dash and has a dot, which PowerShell splits in two"; } } else { if (word.includes("\\")) { return "has a backslash, which the shell reads as an escape"; } if (word.includes("!")) { return "has a !, which some shells read as a command from your history"; } if (needsQuotingAnyway(word, shell)) { return "starts with =, which some shells expand"; } } for (const [chars, meaning] of characterMeanings(shell)) { const ch = chars.exec(word)?.[0]; if (ch) return `has a ${ch}, which ${meaning}`; } return "has symbols that could mean something to the shell"; } /** A composite's stored value is a sub-map of `{ [field]: primitive }`. */ function compositeSubMap(raw: TokenValueLike): Record { const v = raw.value; return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; } /** True when at least one of a composite's sub-fields has a non-empty value. */ function compositeHasValue( def: TokenDefComposite, raw: TokenValueLike, ): boolean { const sub = compositeSubMap(raw); return Object.keys(def.fields).some((f) => String(sub[f] ?? "") !== ""); } /** Resolve one token to its raw (unquoted) string. */ function resolveValue( def: TokenDef, raw: TokenValueLike, opts: AssembleOptions, ): string { if (def.type === "flag") return ""; if (def.type === "composite") { const sub = compositeSubMap(raw); const fieldValues: Record = {}; for (const field of Object.keys(def.fields)) { fieldValues[field] = String(sub[field] ?? ""); } return interpolate(def.template, fieldValues); } const val = String(raw.value ?? ""); if (PATH_TOKEN_TYPES.includes(def.type) && opts.pathTransform && val !== "") { return opts.pathTransform(val); } return val; } /** * The evaluated state of a recipe against the current token values: resolved * values, which tokens are filled, and which args/parts are active. Shared by * command assembly and the UI (token visibility, template display). */ export class RecipeEvaluation { readonly values: Record = {}; constructor( readonly recipe: Recipe, readonly tokenValues: Record, readonly opts: AssembleOptions = {}, ) { for (const [name, def] of Object.entries(recipe.tokens)) { const raw = tokenValues[name]; this.values[name] = raw ? resolveValue(def, raw, opts) : ""; } } /** Switched on and holding a value (a flag only needs to be switched on). */ isFilled(name: string): boolean { const raw = this.tokenValues[name]; const def = this.recipe.tokens[name]; if (!raw?.enabled || !def) return false; if (def.type === "flag") return true; // A composite's assembled string can be non-empty punctuation ("x") even // when unfilled, so judge it by its sub-fields instead. if (def.type === "composite") return compositeHasValue(def, raw); return this.values[name] !== ""; } /** Whether a `when` condition holds, optionally ignoring one token's key. */ conditionHolds(when: Condition | undefined, ignoreToken?: string): boolean { if (!when) return true; return Object.entries(when).every(([name, expected]) => { if (name === ignoreToken) return true; const filled = this.isFilled(name); if (typeof expected === "boolean") return filled === expected; const allowed = Array.isArray(expected) ? expected : [expected]; return filled && allowed.includes(String(this.tokenValues[name].value)); }); } /** * Whether the tokens in some arg text allow it to be included: text with no * tokens always is (static flags); otherwise it's dropped if any required * token is unfilled, and included only if at least one token is filled. */ private textIncluded(text: string): boolean { const tokens = tokensInArg(text); if (tokens.length === 0) return true; let anyFilled = false; for (const name of tokens) { const filled = this.isFilled(name); if (this.recipe.tokens[name]?.required && !filled) return false; if (filled) anyFilled = true; } return anyFilled; } /** Whether an arg (or part) is included in the command. */ isIncluded(spec: ArgSpec): boolean { if (typeof spec === "string") return this.textIncluded(spec); if (!this.conditionHolds(spec.when)) return false; if (spec.parts) { // The leading words may be static ("-vf"); the arg lives or dies by // whether any part survives. const leadOk = tokensInArg(spec.arg ?? "").length === 0 || this.textIncluded(spec.arg ?? ""); return leadOk && spec.parts.some((p) => this.isIncluded(p)); } return this.textIncluded(spec.arg ?? ""); } /** The active parts of an arg, joined into one unquoted string. */ private joinedParts(spec: Exclude): string { return (spec.parts ?? []) .filter((p) => this.isIncluded(p)) .map((p) => this.partText(p)) .join(spec.join ?? ""); } /** A part's interpolated text. A part with its own parts nests its join. */ private partText(spec: ArgSpec): string { if (typeof spec === "string") return interpolate(spec, this.values); if (spec.parts) { return interpolate(spec.arg ?? "", this.values) + this.joinedParts(spec); } return interpolate(spec.arg ?? "", this.values); } /** The unquoted shell words an included arg contributes. */ words(spec: ArgSpec): string[] { if (typeof spec === "string" || !spec.parts) { const text = typeof spec === "string" ? spec : (spec.arg ?? ""); return splitWords(text) .map((w) => interpolate(w, this.values)) .filter((w) => w !== ""); } const lead = splitWords(spec.arg ?? "") .map((w) => interpolate(w, this.values)) .filter((w) => w !== ""); return [...lead, this.joinedParts(spec)]; } /** * Tokens to show in the form: those referenced by an arg (or part) whose * `when` holds — ignoring conditions on the token itself, so a flag that * gates its own arg stays visible while switched off. * * Ordered as the recipe defines its `tokens`, which the author controls * (argument order is dictated by the command, e.g. `-skip_frame` must come * before `-i`, and isn't necessarily the order to ask questions in). */ visibleTokens(): string[] { const visible = new Set(); const visit = (spec: ArgSpec, ancestors: Condition[]) => { const whens = typeof spec === "string" || !spec.when ? ancestors : [...ancestors, spec.when]; for (const name of new Set(tokensInSpec(spec))) { if (whens.every((w) => this.conditionHolds(w, name))) { visible.add(name); } } if (typeof spec !== "string") { for (const part of spec.parts ?? []) visit(part, whens); } }; for (const spec of this.recipe.args) visit(spec, []); const defined = Object.keys(this.recipe.tokens); const ordered = [ ...defined, ...tokensInUse(this.recipe).filter((name) => !defined.includes(name)), ]; return ordered.filter((name) => visible.has(name)); } /** * Visible required tokens that aren't filled. While any remain, the command * is incomplete: their args are dropped, and what's left may still run — * doing something other than intended — so it shouldn't be offered to run. */ missingRequired(): string[] { return this.visibleTokens().filter( (name) => this.recipe.tokens[name]?.required && !this.isFilled(name), ); } /** The command's words after the base, unquoted. */ private allWords(): string[] { return this.recipe.args .filter((a) => this.isIncluded(a)) .flatMap((a) => this.words(a)); } /** The final, shell-quoted command. */ command(): string { const shell = this.opts.shell ?? "posix"; const words = this.allWords().map((w) => shellQuote(w, shell)); return [this.recipe.base, ...words].join(" "); } /** * The words `command()` wraps in quotes, each with the reason, so the UI * can explain quotes that aren't in the recipe. De-duplicated. */ quotedWords(): { quoted: string; reason: string }[] { const shell = this.opts.shell ?? "posix"; const seen = new Set(); const out: { quoted: string; reason: string }[] = []; for (const word of this.allWords()) { const reason = quoteReason(word, shell); const quoted = shellQuote(word, shell); if (!reason || seen.has(quoted)) continue; seen.add(quoted); out.push({ quoted, reason }); } return out; } } /** * Build the final command string from a recipe and the current token values. * * Pure: no React, no store, no cwd. Path handling is injected via * `opts.pathTransform`. * * Arg inclusion: * - an arg whose `when` condition fails is dropped; * - args with no tokens are always included (static flags); * - an arg is dropped if any *required* token it references is unfilled; * - otherwise it is included only when at least one of its tokens is filled. * * Each included arg is split into words on whitespace, each word interpolated * and then shell-quoted as a whole. */ export function assembleCommand( recipe: Recipe, tokenValues: Record, opts: AssembleOptions = {}, ): string { return new RecipeEvaluation(recipe, tokenValues, opts).command(); }