419 lines
10 KiB
JavaScript
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
|
|
}
|