/** * Pipeline / redirection parse helpers and execution-graph snapshots. */ import { tokenizeBareShellLineDetailed } from './shell-tokenizer.js' import { shellWordText, tokenize } from './shell-token.js' /** * @typedef {import('./shell-token.js').Token} Token * @typedef {{ * argv: Extract[], * assign: Record, * redirIn: Extract | null, * redirOut: Extract | null, * redirAppend: boolean, * redirErr: Extract | null, * redirErrAppend: boolean, * mergeStderrToStdout: boolean, * redirHereDoc: string | null * }} SimpleCmd */ /** * Structured syntax error that callers can route differently from expansion/runtime failures. * @param {string} message * @param {{ index?: number, phase?: 'tokenize' | 'parse' | 'expand' | 'runtime' }} [meta] */ export function bareOsShellError(message, meta = {}) { const e = new Error(String(message || 'shell error')) e.code = 'BARE_OS_SHELL_ERROR' e.shellPhase = meta.phase || 'runtime' if (Number.isFinite(meta.index)) e.shellIndex = Number(meta.index) return e } /** @param {Token[]} seg */ export function parseSimpleCommand(seg) { /** @type {Record} */ const assign = {} /** @type {Extract | null} */ let redirIn = null /** @type {Extract | null} */ let redirOut = null let redirAppend = false /** @type {Extract | null} */ let redirErr = null let redirErrAppend = false let mergeStderrToStdout = false /** @type {string | null} */ let redirHereDoc = null /** @type {Extract[]} */ const argvWords = [] let seenCommand = false let w = 0 while (w < seg.length) { const t = seg[w] if (t.type === 'op') { if (t.value === '2>' || t.value === '2>>') { const n = seg[w + 1] if (n && n.type === 'word') { redirErrAppend = t.value === '2>>' redirErr = n w += 2 continue } } if (t.value === '2>&1') { mergeStderrToStdout = true w++ continue } if (t.value === '>' || t.value === '>>') { const n = seg[w + 1] if (n && n.type === 'op' && n.value === '(') { throw bareOsShellError( 'shell: process substitution >(…) is unsupported', { phase: 'parse' } ) } if (n && n.type === 'word') { redirAppend = t.value === '>>' redirOut = n w += 2 continue } } if (t.value === '<') { const n = seg[w + 1] if (n && n.type === 'op' && n.value === '(') { throw bareOsShellError( 'shell: process substitution <(…) is unsupported', { phase: 'parse' } ) } if (n && n.type === 'word') { redirIn = n w += 2 continue } } if (t.value === '<<' || t.value === '<<-') { const n = seg[w + 1] if (n && n.type === 'word') { w += 2 continue } } if (t.value === '<<<') { const n = seg[w + 1] if (n && n.type === 'word') { redirHereDoc = n.value w += 2 continue } } w++ continue } const v = t.value if (!seenCommand) { const eq = v.indexOf('=') if (eq > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(v.slice(0, eq))) { assign[v.slice(0, eq)] = v.slice(eq + 1) w++ continue } } seenCommand = true argvWords.push(/** @type {Extract} */ (t)) w++ } let i = 0 while (i < argvWords.length) { const wt = (k) => shellWordText(argvWords[i + k]) if ( wt(0) === '2' && wt(1) === '>' && wt(2) === '&' && wt(3) === '1' ) { mergeStderrToStdout = true argvWords.splice(i, 4) continue } if (wt(0) === '2' && wt(1) === '>') { redirErr = argvWords[i + 2] ?? null redirErrAppend = false argvWords.splice(i, 3) continue } if (wt(0) === '2' && wt(1) === '>>') { redirErr = argvWords[i + 2] ?? null redirErrAppend = true argvWords.splice(i, 3) continue } if (wt(0) === '>') { redirOut = argvWords[i + 1] ?? null redirAppend = false argvWords.splice(i, 2) continue } if (wt(0) === '>>') { redirOut = argvWords[i + 1] ?? null redirAppend = true argvWords.splice(i, 2) continue } if (wt(0) === '<') { redirIn = argvWords[i + 1] ?? null argvWords.splice(i, 2) continue } if (wt(0) === '<<<') { redirHereDoc = wt(1) || '' argvWords.splice(i, 2) continue } if (wt(0) === '<<' || wt(0) === '<<-') { argvWords.splice(i, 2) continue } i++ } return { argv: argvWords, assign, redirIn, redirOut, redirAppend, redirErr, redirErrAppend, mergeStderrToStdout, redirHereDoc } } /** * @param {Token[]} tokens * @returns {SimpleCmd[][]} */ export function parsePipeline(tokens) { /** @type {Token[][]} */ const pipes = [[]] for (const t of tokens) { if (t.type === 'op' && t.value === '|') { pipes.push([]) } else { pipes[pipes.length - 1].push(t) } } return pipes.map((seg) => parseSimpleCommand(seg)) } /** * Snapshot-friendly parse artifact for deterministic grammar tests. * @param {string} line */ export function bareOsShellAstSnapshot(line) { const src = String(line || '') return { schema: 1, line: src, diagnosticTokens: tokenizeBareShellLineDetailed(src), tokens: tokenize(src), pipeline: parsePipeline(tokenize(src)) } } /** * Split token list on `;` into separate commands (AND-OR lists). * @param {Token[]} tokens * @returns {Token[][]} */ export function splitTokensBySemicolon(tokens) { /** @type {Token[][]} */ const lists = [] /** @type {Token[]} */ let cur = [] let kwDepth = 0 let parenDepth = 0 let braceDepth = 0 for (const t of tokens) { if (t.type === 'word') { if ( t.value === 'if' || t.value === 'while' || t.value === 'until' || t.value === 'for' || t.value === 'select' || t.value === 'case' ) kwDepth++ else if ( t.value === 'fi' || t.value === 'done' || t.value === 'esac' ) kwDepth = Math.max(0, kwDepth - 1) } else if (t.type === 'op') { if (t.value === '(') parenDepth++ else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1) else if (t.value === '{') braceDepth++ else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1) } if ( t.type === 'op' && t.value === ';' && kwDepth === 0 && parenDepth === 0 && braceDepth === 0 ) { lists.push(cur) cur = [] } else { cur.push(t) } } lists.push(cur) return lists } /** * Split one semicolon-separated list on `&&` / `||` (left-associative chain). * @param {Token[]} tokens * @returns {{ segments: Token[][], ops: string[] }} */ export function splitTokensByAndOr(tokens) { /** @type {Token[][]} */ const segments = [] /** @type {string[]} */ const ops = [] /** @type {Token[]} */ let cur = [] let kwDepth = 0 let parenDepth = 0 let braceDepth = 0 for (const t of tokens) { if (t.type === 'word') { if ( t.value === 'if' || t.value === 'while' || t.value === 'until' || t.value === 'for' || t.value === 'select' || t.value === 'case' ) kwDepth++ else if ( t.value === 'fi' || t.value === 'done' || t.value === 'esac' ) kwDepth = Math.max(0, kwDepth - 1) } else if (t.type === 'op') { if (t.value === '(') parenDepth++ else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1) else if (t.value === '{') braceDepth++ else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1) } if ( t.type === 'op' && (t.value === '&&' || t.value === '||') && kwDepth === 0 && parenDepth === 0 && braceDepth === 0 ) { segments.push(cur) ops.push(t.value) cur = [] } else { cur.push(t) } } segments.push(cur) return { segments, ops } } /** @param {Token[]} seg */ export function segmentHasCommand(seg) { if (!seg.length) return false try { const cmd = parseSimpleCommand(seg) return cmd.argv.length > 0 || Object.keys(cmd.assign).length > 0 } catch (e) { if (e && /** @type {{ code?: string }} */ (e).code === 'BARE_OS_SHELL_ERROR') return true throw e } } /** * Normalized redirection plan independent from execution side-effects. * @param {SimpleCmd} cmd */ export function planShellRedirections(cmd) { return { stdin: cmd.redirHereDoc != null ? 'heredoc' : cmd.redirIn ? 'file' : 'inherit', stdout: cmd.redirOut ? (cmd.redirAppend ? 'append' : 'truncate') : 'inherit', stderr: cmd.mergeStderrToStdout ? 'stdout' : cmd.redirErr ? cmd.redirErrAppend ? 'append' : 'truncate' : 'inherit' } } /** * Execution graph (lists -> and/or -> pipelines) for debugging and tests. * @param {string} line */ export function buildShellExecutionGraph(line) { const tokens = tokenize(String(line || '')) const lists = splitTokensBySemicolon(tokens) const graph = { schema: 1, line: String(line || ''), listCount: lists.length, lists: [] } for (const list of lists) { const { segments, ops } = splitTokensByAndOr(list) const entry = { andOrOps: ops.slice(), segments: [] } for (const seg of segments) { let pipe try { pipe = parsePipeline(seg) } catch (e) { const code = e && /** @type {{ code?: string }} */ (e).code entry.segments.push({ parseError: code === 'BARE_OS_SHELL_ERROR' ? (e && /** @type {Error} */ (e).message) || String(e) : String((e && /** @type {Error} */ (e).message) || e) }) continue } entry.segments.push({ pipelineLength: pipe.length, commands: pipe.map((cmd) => ({ argv: cmd.argv.map((w) => shellWordText(w)), redirections: planShellRedirections(cmd) })) }) } graph.lists.push(entry) } return graph }