Files
bare-operating-system/packages/bare-os-booter/lib/shell/shell-parse.js
T
2026-08-18 18:11:28 -04:00

419 lines
10 KiB
JavaScript

/**
* 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<Token, { type: 'word' }>[],
* assign: Record<string, string>,
* redirIn: Extract<Token, { type: 'word' }> | null,
* redirOut: Extract<Token, { type: 'word' }> | null,
* redirAppend: boolean,
* redirErr: Extract<Token, { type: 'word' }> | 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<string, string>} */
const assign = {}
/** @type {Extract<Token, { type: 'word' }> | null} */
let redirIn = null
/** @type {Extract<Token, { type: 'word' }> | null} */
let redirOut = null
let redirAppend = false
/** @type {Extract<Token, { type: 'word' }> | null} */
let redirErr = null
let redirErrAppend = false
let mergeStderrToStdout = false
/** @type {string | null} */
let redirHereDoc = null
/** @type {Extract<Token, { type: 'word' }>[]} */
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<Token, { type: 'word' }>} */ (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
}