UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

1,020 lines • 38.2 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { expandTilde } from "../utils/path.js"; import { encodeText } from "./bytes.js"; import { FD_BOTH, FD_CLOSE, FD_STDERR, FD_STDIN, FD_STDOUT } from "./constants.js"; import { decodeAnsiC, unescapeDquoted, unescapeUnquoted } from "./escapes.js"; import { ProgramNode } from "./parse/program.js"; import { NodeType as NT, ProcessSubDirection, Redirect, RedirectKind } from "./types.js"; export function getText(node) { return node.text; } /** The root of the tree `node` belongs to. */ export function treeRoot(node) { let root = node; while (root.parent != null) root = root.parent; return root; } /** * Whether `after` stands on `before`'s line within `root`: no newline between * them. The parse has joined continued lines and folded each heredoc body * into its statement, so a newline between two statements is a line break. * Mirrors Python's same_line. */ export function sameLine(root, before, after) { const base = root.startIndex ?? 0; const gap = root.text.slice((before.endIndex ?? base) - base, (after.startIndex ?? base) - base); return !gap.includes('\n'); } /** * The row the shell began reading `node`'s command on. bash reads a whole * line before running any of it, and a complete command spanning lines * whole, with the rest of the line it ends on: a command after a `;`, or * inside a group that spans rows, was read with the ones before it. Mirrors * Python's read_row. */ export function readRow(node) { let top = node; while (top.parent?.parent != null) top = top.parent; const root = top.parent ?? top; for (let before = top.previousSibling; before != null && sameLine(root, before, top);) [top, before] = [before, before.previousSibling]; return top.startPosition?.row ?? 0; } /** * A node's children with the source text between them, the twin of * python's `source_parts`. * * The parser consumes some text without giving it a node: * whitespace and newlines inside a double-quoted string, and the whitespace * or line continuation opening a `${v:-word}` operand. Only the node's own * source still holds that text, so it is sliced out between child extents * rather than rebuilt from row or byte counts, which lose tabs, newlines and * escapes. The parser counts `startIndex` here in UTF-16 code units, the * units `text.slice` takes. */ export function* sourceParts(node) { const start = node.startIndex ?? 0; let end = start; for (const child of node.children) { const childStart = child.startIndex ?? end; if (childStart > end) yield node.text.slice(end - start, childStart - start); end = child.endIndex ?? childStart + child.text.length; yield child; } } /** * Walk a double-quoted string without losing scanner-owned text. * * The text between children is the string's own, and the closing quote * token can carry the whitespace before it. Expansion nodes keep their own * folded prefixes. */ export function* quotedParts(node) { for (const part of sourceParts(node)) { if (typeof part === 'string') yield unescapeDquoted(part); else if (part.type === NT.DQUOTE) yield unescapeDquoted(part.text.slice(0, -1)); else yield part; } } /** * Where an index into a node's text falls in the parser's offsets, the twin * of python's `byte_offset`. * * The Python parser places a node by the bytes of the UTF-8 source, so an * index counted in code units reads one place too early for every * multibyte character before it. `grep -ob` reads the same answer for the * same reason. * `encodeText` rather than `TextEncoder` because a byte that is not valid * UTF-8 rides as a surrogate escape and stands for one byte. That is a * requirement on the caller, not a hope: grep's family decodes every line * through `decodeText` for it. */ export function byteOffset(text, index) { return encodeText(text.slice(0, index)).length; } export function getCommandName(node) { for (const c of node.namedChildren) { if (c.type === NT.COMMAND_NAME) return c.text; } return ''; } export function getParts(node) { // A bare `$` word is an anonymous token rather than a named child, but // bash passes it through as a literal argument (`echo $` prints `$`), so // it is the one anonymous child that stays - unless a string starts at // its very next byte, where it is the translation marker of `$"..."` and // the string node carries the whole word. const children = node.children; const parts = []; for (let position = 0; position < children.length; position += 1) { const c = children[position]; if (c === undefined) continue; if (c.isNamed === true && !REDIRECT_NODE_TYPES.has(c.type)) { parts.push(c); } else if (c.type === '$') { const nxt = children[position + 1]; if (nxt?.type !== NT.STRING || nxt.startIndex !== c.endIndex) { parts.push(c); } } } return parts; } /** * Whether unquoted text holds a brace expansion (`{a,b}`, `{1..3}`), * which the shell turns into several words. */ export function braceExpands(text) { let start = -1; for (let position = 0; position < text.length; position += 1) { const char = text[position]; if (char === '{') { start = position; } else if (char === '}' && start >= 0) { const body = text.slice(start + 1, position); if (body.includes(',') || body.includes('..')) return true; start = -1; } } return false; } /** * The text a word names before any expansion, or null. * * A word is literal when nothing in it waits on the shell: a plain * word, a number, a quoted string with no expansion inside, or a * concatenation of those. Quotes are removed, escapes resolved and a * leading unquoted `~` expanded the way expansion would (`home` null * leaves it literal, as bash does with no `$HOME`). A word carrying a * parameter, command, arithmetic or process substitution, or a brace * expression, answers null: what it names is known only when it runs. */ export function literalWord(node, home = null) { const ntype = node.type; if (ntype === NT.COMMAND_NAME) { const first = node.namedChildren[0]; return first === undefined ? node.text : literalWord(first, home); } if ((ntype === NT.WORD || ntype === NT.NUMBER || ntype === NT.CONCATENATION) && braceExpands(node.text)) { return null; } if (ntype === NT.WORD || ntype === NT.NUMBER) { return expandTilde(unescapeUnquoted(node.text), home); } if (ntype === NT.RAW_STRING) return node.text.slice(1, -1); if (ntype === NT.ANSI_C_STRING) return decodeAnsiC(node.text.slice(2, -1)); if (ntype === NT.TRANSLATED_STRING) { for (const child of node.namedChildren) { if (child.type === NT.STRING) return literalWord(child); } return ''; } if (ntype === NT.STRING) { const pieces = []; for (const child of quotedParts(node)) { if (typeof child === 'string') { pieces.push(child); continue; } if (child.type !== NT.STRING_CONTENT) return null; pieces.push(unescapeDquoted(child.text)); } return pieces.join(''); } if (ntype === NT.CONCATENATION) { const pieces = []; const children = node.children; for (let position = 0; position < children.length; position += 1) { const child = children[position]; if (child === undefined) continue; // The `$` of a `$"..."` is the translation marker, not text. if (child.type === '$' && children[position + 1]?.type === NT.STRING) continue; // Only a leading unquoted piece carries a tilde prefix. const piece = literalWord(child, pieces.length === 0 ? home : null); if (piece === null) return null; pieces.push(piece); } return pieces.join(''); } if (ntype === '$') return '$'; return null; } /** Split FOO=1 BAR=2 cmd parts into [assignments, remaining]. */ export function splitEnvPrefix(parts) { const assignments = []; const remaining = []; let sawCommandName = false; for (const p of parts) { if (!sawCommandName && p.type === NT.VARIABLE_ASSIGNMENT) { assignments.push(p); continue; } if (p.type === NT.COMMAND_NAME) sawCommandName = true; remaining.push(p); } return [assignments, remaining]; } export function getPipelineCommands(node) { const commands = []; const stderrFlags = []; for (const c of node.children) { if (c.isNamed === true) { commands.push(c); } else if (c.type === NT.PIPE || c.type === NT.PIPE_STDERR) { stderrFlags.push(c.type === NT.PIPE_STDERR); } } return [commands, stderrFlags]; } /** * A pipeline's stages as bash reads them, whatever shape the parse gave * them. * * The parser lets a redirect close over everything to its left up * to the next pipe, so `a && b | c < f | d` parses as a pipeline whose * first stage is `redirected(a && b | c, < f)`, and `! a < f | b` as one * whose first stage is `redirected(! a, < f)`. Bash reads them as * `a && (b | c <f | d)` and `! (a <f | b)`: the redirect binds to the * command it follows, the stages on both sides of it are one pipeline, a * `!` negates all of it, and a list the parse pulled into the first * stage runs ahead of the pipeline and decides whether it runs at all. * This is the last-command chain the admission gate climbs for a * redirect's target (`statementRedirects`), read in the direction the * executor walks. `redirects` are hoisted over the whole pipeline and * bind to its last stage. */ export function getPipelineStages(node, redirects = []) { const [commands, stderrFlags] = getPipelineCommands(node); const [first, ...rest] = commands; if (first === undefined) throw new Error('pipeline: missing command'); const head = pipelineHead(first); return bindLast({ commands: [...head.commands, ...rest], stderrFlags: [...head.stderrFlags, ...stderrFlags], redirects: [...head.redirects, ...rest.map(() => [])], negated: head.negated, lead: head.lead, }, redirects); } // `stages` with `redirects` bound to the last stage, after any it already // carries (the inner ones come first in the source). function bindLast(stages, redirects) { if (redirects.length === 0) return stages; const carried = stages.redirects[stages.redirects.length - 1] ?? []; const command = stages.commands[stages.commands.length - 1]; const last = carried.length > 0 || command === undefined ? [...carried, ...redirects] : withOwnRedirects(command, redirects); return { ...stages, redirects: [...stages.redirects.slice(0, -1), last], }; } // The stages a pipeline's first element stands for. Only a redirected // statement over a pipeline, a list or a negation is re-read; every other // stage, a redirected command included, is one stage that runs as the // node it is. function pipelineHead(stage) { const single = { commands: [stage], stderrFlags: [], redirects: [[]], negated: false, lead: null, }; let body = stage; let hoisted = []; if (stage.type === NT.REDIRECTED_STATEMENT) { const [found, parsed] = getRedirects(stage); if (found === null) return single; body = found; hoisted = parsed; } if (body.type === NT.NEGATED_COMMAND) { const inner = getNegatedCommand(body); if (inner === null) return single; return { commands: [inner], stderrFlags: [], redirects: [hoisted.length > 0 ? withOwnRedirects(inner, hoisted) : []], negated: true, lead: null, }; } if (hoisted.length === 0) return single; if (body.type === NT.PIPELINE) return getPipelineStages(body, hoisted); if (body.type === NT.LIST) { const [left, op, right] = getListParts(body); const inner = right.type === NT.PIPELINE ? getPipelineStages(right, hoisted) : bindLast(pipelineHead(right), hoisted); if (inner.lead === null) return { ...inner, lead: [left, op, right] }; } return single; } export function getWhileParts(node) { const test = [...node.namedChildren]; const body = test.pop(); return [test, body !== undefined ? [...body.namedChildren] : []]; } /** * Get (variable, values, bodyCommands) from for/select. `values` is null for * a header with no `in` (`for f do`), which loops over `"$@"`. */ export function getForParts(node) { const nc = node.namedChildren; const first = nc[0]; const last = nc[nc.length - 1]; if (first === undefined || last === undefined) throw new Error('for: missing parts'); const values = node.children.some((c) => c.type === NT.IN) ? nc.slice(1).filter((c) => c.type !== NT.DO_GROUP && c.type !== NT.COMMENT) : null; return [getText(first), values, [...last.namedChildren]]; } /** * ([init, cond, update], body) from a C-style for. The expression slots * are positional between the (( )) delimiters, separated by `;` tokens, * and any of them may be empty: `for ((;;))`. A slot holds every node and * token the parser found in it, in order, commas and stray operators * included, since bash evaluates the slot's text as one expression: `for * ((a=1, i=0; ...))` assigns both, and `i<1 2` is an error, not two * expressions. */ export function getCforParts(node) { const exprs = [[], [], []]; let slot = 0; let inside = false; let body = []; for (const child of node.children) { if (child.type === NT.ARITH_OPEN) { inside = true; continue; } if (child.type === NT.ARITH_CLOSE) { inside = false; continue; } if (inside) { if (child.type === NT.SEMI) slot += 1; else if (slot < 3) exprs[slot]?.push(child); continue; } if (child.type === NT.DO_GROUP) body = [...child.namedChildren]; } return [exprs, body]; } /** * Whether a statement's terminator is `&`. * * The parser puts the `&` beside the statement it ends, inside whatever * body holds them both, so a body read as named children (every * extractor above) never sees it. Asking the statement about its own * next sibling is what lets a loop body, an if/case arm, a brace group * and a function body launch the job the program loop launches for a * top-level `cmd &`. */ export function isBackgrounded(node) { return node.nextSibling?.type === NT.BACKGROUND; } export const REDIRECT_NODE_TYPES = new Set([ NT.FILE_REDIRECT, NT.HERESTRING_REDIRECT, ]); // RAW_STRING (single quotes) belongs here alongside STRING (double // quotes): quoting a redirect target is purely syntactic in bash, so // `> 'f'`, `> "f"` and `> f` name the same file. Omitting it left // targetNode null and target '', which silently redirected every // single-quoted target to one phantom empty path instead of the file. const TARGET_TYPES = new Set([ NT.WORD, NT.CONCATENATION, NT.SIMPLE_EXPANSION, NT.EXPANSION, NT.COMMAND_SUBSTITUTION, NT.ARITHMETIC_EXPANSION, NT.STRING, NT.RAW_STRING, NT.ANSI_C_STRING, NT.TRANSLATED_STRING, NT.PROCESS_SUBSTITUTION, ]); const INPUT_OPERATORS = new Set([ NT.REDIRECT_IN, NT.REDIRECT_DUP_IN, NT.REDIRECT_CLOSE_IN, ]); const CLOSE_OPERATORS = new Set([NT.REDIRECT_CLOSE_OUT, NT.REDIRECT_CLOSE_IN]); const DUP_OPERATORS = new Set([NT.REDIRECT_STDERR, NT.REDIRECT_DUP_IN]); const BOTH_OPERATORS = new Set([NT.REDIRECT_BOTH, NT.REDIRECT_BOTH_APPEND]); const REDIRECT_OPERATORS = new Set([ ...INPUT_OPERATORS, ...CLOSE_OPERATORS, ...DUP_OPERATORS, ...BOTH_OPERATORS, NT.REDIRECT_OUT, NT.REDIRECT_CLOBBER, NT.REDIRECT_APPEND, NT.REDIRECT_READWRITE, ]); /** * Parse a single file_redirect node into a Redirect. * * The operator token decides the shape and the explicit descriptor, when * there is one, is kept as typed: `3<f` claims fd 3 and `<&3` duplicates * from it, and both are refused downstream rather than read as stdin * (`shell/descriptors.ts`). Three forms carry a numeric target: a dup * (`2>&1`, `>&2`, `<&0`) names the descriptor it copies, a close (`>&-`, * `<&-`) carries FD_CLOSE, and `&>` claims FD_BOTH. `2>&1` alone keeps the * STDERR_TO_STDOUT kind the fd router keys on; every other output redirect * is STDOUT or STDERR by the descriptor it claims. */ function parseFileRedirect(child) { let fd = null; let target = ''; let targetNode = null; let op = null; let dupFd = null; for (const c of child.children) { if (c.type === NT.FILE_DESCRIPTOR) { fd = parseInt(getText(c), 10); } else if (REDIRECT_OPERATORS.has(c.type)) { op = c.type; } else if (c.type === NT.NUMBER) { dupFd = parseInt(getText(c), 10); } } for (const c of child.namedChildren) { if (TARGET_TYPES.has(c.type)) { target = getText(c); targetNode = c; break; } } if (child.heredoc !== undefined) { return new Redirect({ fd: fd ?? 0, target: child.heredoc.body, targetNode, kind: RedirectKind.HEREDOC, expandVars: !child.heredoc.quoted, }); } // `>&word` with a word rather than a number is bash's other spelling // of `&>word`, bare or on descriptor 1 (`1>&word` sends both streams // too, pinned on bash 5.2). On any other explicit descriptor bash // refuses it as `word: ambiguous redirect`, before the command runs and // before any file opens, so the parse keeps the word for the message // rather than turning `3>&foo` into a both-streams file. const wordDup = op === NT.REDIRECT_STDERR && dupFd === null && targetNode !== null; if (wordDup && fd !== null && fd !== FD_STDOUT) { return new Redirect({ fd, target, targetNode, kind: RedirectKind.AMBIGUOUS }); } if ((op !== null && BOTH_OPERATORS.has(op)) || wordDup) { return new Redirect({ fd: FD_BOTH, target, targetNode, kind: RedirectKind.STDOUT, append: op === NT.REDIRECT_BOTH_APPEND, }); } const input = op === NT.REDIRECT_READWRITE || (op !== null && INPUT_OPERATORS.has(op)); fd ??= input ? FD_STDIN : FD_STDOUT; if (op !== null && CLOSE_OPERATORS.has(op)) target = FD_CLOSE; else if (op !== null && DUP_OPERATORS.has(op) && dupFd !== null) target = dupFd; let kind; if (op === NT.REDIRECT_READWRITE) kind = RedirectKind.READWRITE; else if (input) kind = RedirectKind.STDIN; else if (fd === FD_STDERR && target === FD_STDOUT && op === NT.REDIRECT_STDERR) { kind = RedirectKind.STDERR_TO_STDOUT; } else if (fd === FD_STDERR) kind = RedirectKind.STDERR; else kind = RedirectKind.STDOUT; return new Redirect({ fd, target, targetNode, kind, append: op === NT.REDIRECT_APPEND, clobber: op === NT.REDIRECT_CLOBBER, }); } function parseHerestringRedirect(child) { const descriptor = child.namedChildren.find((candidate) => candidate.type === NT.FILE_DESCRIPTOR); const word = child.namedChildren.find((candidate) => candidate.type !== NT.FILE_DESCRIPTOR); return new Redirect({ fd: descriptor === undefined ? 0 : parseInt(getText(descriptor), 10), target: word === undefined ? '' : getText(word), targetNode: word ?? null, kind: RedirectKind.HERESTRING, }); } /** * Parse all redirects from a redirected_statement. * * Returns [command, redirects]; command is null for a bare redirect * like `> file` (bash runs the empty command and applies redirects, * creating/truncating the file). */ export function getRedirects(node) { const nc = node.namedChildren; const first = nc[0]; const command = first !== undefined && !REDIRECT_NODE_TYPES.has(first.type) ? first : null; const redirects = []; for (let i = command === null ? 0 : 1; i < nc.length; i++) { const child = nc[i]; if (child?.type === NT.FILE_REDIRECT) { redirects.push(parseFileRedirect(child)); } else if (child?.type === NT.HERESTRING_REDIRECT) { redirects.push(parseHerestringRedirect(child)); } } if (command?.type === NT.DECLARATION_COMMAND || command?.type === NT.UNSET_COMMAND) { return [command, [...commandRedirects(command), ...redirects]]; } if (command?.type !== NT.COMMAND) return [command, redirects]; return [ getParts(command).length === 0 ? null : command, [...commandRedirects(command), ...redirects], ]; } /** * The redirects written among a simple command's words, in order: bash * applies them wherever they stand (`> f echo hi`, `cat <<< x`). */ export function commandRedirects(node) { const redirects = []; for (const child of node.namedChildren) { if (child.type === NT.FILE_REDIRECT) redirects.push(parseFileRedirect(child)); else if (child.type === NT.HERESTRING_REDIRECT) redirects.push(parseHerestringRedirect(child)); } return redirects; } /** * Redirects hoisted onto a command, after the ones written among its words: * bash applies them in source order (`true && > a echo > b`). Mirrors * Python's with_own_redirects. */ export function withOwnRedirects(command, redirects) { const own = command.type === NT.COMMAND ? commandRedirects(command) : []; return [...own, ...redirects]; } export function getListParts(node) { const left = node.namedChildren[0]; const right = node.namedChildren[1]; if (left === undefined || right === undefined) throw new Error('list: missing parts'); let op = null; for (const c of node.children) { if (c.type === NT.AND || c.type === NT.OR || c.type === NT.SEMI) { op = c.type; break; } } return [left, op, right]; } /** Split an `if` or `elif` at its `then`: the test statements before it, the rest after. */ function testAndBody(node) { const test = []; const rest = []; let part = test; for (const c of node.children) { if (c.type === 'then') part = rest; else if (c.isNamed === true) part.push(c); } return [test, rest]; } export function getIfBranches(node) { const [test, rest] = testAndBody(node); const branches = [[test, []]]; let elseBody = null; for (const c of rest) { if (c.type === NT.ELIF_CLAUSE) branches.push(testAndBody(c)); else if (c.type === NT.ELSE_CLAUSE) elseBody = [...c.namedChildren]; else branches[branches.length - 1]?.[1].push(c); } return [branches, elseBody]; } export function getCaseWord(node) { const first = node.namedChildren[0]; if (first === undefined) throw new Error('case: missing word'); return first; } /** * Get (patternNodes, bodyStatements, terminator) triples from case. * * Patterns are every named child before the arm's `)`, kept as nodes so * quoting survives to the matcher: 'a'), "$x") and $'a\n') all mean literal * text where a bare word keeps its globs live. An arm's body is every * statement up to its terminator, so multi-statement arms * (x) cmd1; cmd2;;) keep all commands. */ export function getCaseItems(node) { const items = []; for (const c of node.namedChildren) { if (c.type !== NT.CASE_ITEM) continue; const patterns = []; const body = []; let terminator = ';;'; let inBody = false; for (const child of c.children) { if (child.type === ';;' || child.type === ';&' || child.type === ';;&') { terminator = child.type; } else if (child.type === ')') { inBody = true; } else if (child.isNamed !== true) { continue; } else if (inBody) { body.push(child); } else { patterns.push(child); } } items.push([patterns, body, terminator]); } return items; } export function getDeclarationKeyword(node) { return node.children.find((c) => !REDIRECT_NODE_TYPES.has(c.type))?.type ?? ''; } /** * Split a whitespace-separated operand string, honoring single and * double quotes the way the shell does. Returns null on an unbalanced * quote so the caller can fall back. Mirrors Python's `shlex.split` for * the un-expanded operand cases `unset` needs. */ function shellSplit(text) { const tokens = []; let cur = ''; let has = false; let inSingle = false; let inDouble = false; for (let i = 0; i < text.length; i++) { const ch = text.charAt(i); if (inSingle) { if (ch === "'") inSingle = false; else cur += ch; continue; } if (inDouble) { if (ch === '"') inDouble = false; else if (ch === '\\' && (text.charAt(i + 1) === '"' || text.charAt(i + 1) === '\\')) cur += text.charAt(++i); else cur += ch; continue; } if (ch === "'") { inSingle = true; has = true; } else if (ch === '"') { inDouble = true; has = true; } else if (ch === '\\' && i + 1 < text.length) { cur += text.charAt(++i); has = true; } else if (ch === ' ' || ch === '\t' || ch === '\n') { if (has) { tokens.push(cur); cur = ''; has = false; } } else { cur += ch; has = true; } } if (inSingle || inDouble) return null; if (has) tokens.push(cur); return tokens; } /** * Get every operand word of an unset_command, keeping `-f`/`-v`/`-n` and * keeping a subscript target (`unset arr[1]`, quoted or not) as one word. * A redirect among or before the words is no operand. Mirrors Python's * `get_unset_args`. */ export function getUnsetArgs(node) { const operands = node.children.filter((c) => !REDIRECT_NODE_TYPES.has(c.type)).slice(1); const first = operands[0]; if (first === undefined) return []; if (first.startIndex !== undefined && node.startIndex !== undefined) { let text = node.text; for (const child of node.children) { if (!REDIRECT_NODE_TYPES.has(child.type)) continue; if (child.startIndex === undefined || child.endIndex === undefined) continue; const start = child.startIndex - node.startIndex; const end = child.endIndex - node.startIndex; text = text.slice(0, start) + ' '.repeat(end - start) + text.slice(end); } const split = shellSplit(text.slice(first.startIndex - node.startIndex)); if (split !== null) return split; } return operands.filter((c) => c.isNamed === true).map((c) => getText(c)); } /** The command a `!` negates; null for a bare `!`, which negates the empty command. */ export function getNegatedCommand(node) { return node.namedChildren[0] ?? null; } export function getProcessSubDirection(node) { const open = node.children[0]?.type ?? ''; if (open === '<(') return ProcessSubDirection.INPUT; if (open === '>(') return ProcessSubDirection.OUTPUT; return null; } /** * Bash's single-file command substitution, measured against 5.2.37. * Only a lone foreground `< file` (optionally `0<`) reads input into * the substitution. Extra redirects, commands, heredocs and descriptor * duplication retain ordinary redirect-only semantics. */ export function inputSubstitutionRedirect(node) { if (node.children.some((child) => child.type === '&')) return null; const statements = node.namedChildren.filter((child) => child.type !== NT.COMMENT); const statement = statements[0]; if (statements.length !== 1 || statement === undefined) return null; if (statement.type !== NT.REDIRECTED_STATEMENT && statement.type !== NT.FILE_REDIRECT) return null; const [command, redirects] = statement.type === NT.FILE_REDIRECT ? [null, [parseFileRedirect(statement)]] : getRedirects(statement); const redirect = redirects[0]; if (command !== null || redirects.length !== 1 || redirect?.kind !== RedirectKind.STDIN || redirect.fd !== 0 || typeof redirect.target === 'number') return null; return redirect; } /** A process substitution's command text, with the heredoc bodies the line * reads after it moved inside it. */ export function getProcessSubBody(node) { const text = node.inlined ?? node.sourceText ?? node.text; if ((text.startsWith('<(') || text.startsWith('>(')) && text.endsWith(')')) { return text.slice(2, -1); } return text; } export function getFunctionName(node) { const first = node.namedChildren[0]; return first !== undefined ? getText(first) : ''; } /** * Get function body commands: the compound_statement's children, or any * other compound command (`f() ( ... )`) as the one statement. The redirects * a definition carries, its own and those of a statement it is the body of * (`f() { ...; } >o 2>&1`), apply at every call, as bash's do, so then the * body is one statement: the group under them. Mirrors Python's * get_function_body. */ /** * The redirects a function definition carries: its own, and those of a * statement it is the body of (`f() { ...; } >o 2>&1`, whose second * redirect the parser hangs on a redirected_statement around it). */ export function getFunctionRedirects(node) { const redirects = node.namedChildren.filter((c) => REDIRECT_NODE_TYPES.has(c.type)); const outer = node.parent; if (outer?.type === NT.REDIRECTED_STATEMENT && outer.namedChildren[0]?.id !== undefined && outer.namedChildren[0].id === node.id) { for (const item of outer.namedChildren.slice(1).filter((c) => REDIRECT_NODE_TYPES.has(c.type))) redirects.push(item); } return redirects; } /** * Where a node's own words end in the original source. A heredoc redirect * ends at its delimiter word: the lowering folds the body into the * redirect's span, and the body sits after whatever else the line goes on * to say. */ function syntaxEnd(node, offsets) { if (node.heredoc !== undefined) return node.heredoc.wordEnd; if (node.children.length > 0) { let end = 0; for (const child of node.children) end = Math.max(end, syntaxEnd(child, offsets)); return end; } const startIndex = node.startIndex ?? 0; const endIndex = node.endIndex ?? startIndex; if (endIndex > startIndex) return (offsets?.[endIndex - 1] ?? endIndex - 1) + 1; return offsets?.[startIndex] ?? startIndex; } /** The definition's source, including redirects and heredoc bodies. */ export function getFunctionSource(node) { const source = node.parent?.type === NT.REDIRECTED_STATEMENT ? node.parent : node; if (source.startIndex === undefined || source.endIndex === undefined) return source.sourceText ?? source.text; const root = treeRoot(source); const original = source instanceof ProgramNode ? source.program.original : (root.sourceText ?? root.text); const offsets = source instanceof ProgramNode ? source.program.offsets : root.offsets; const start = offsets?.[source.startIndex] ?? source.startIndex; const end = syntaxEnd(source, offsets); let text = original.slice(start, end); // A heredoc can follow the closing brace and other commands; copy only its body and delimiter. const documents = new Map(); const pending = [source]; for (;;) { const current = pending.pop(); if (current === undefined) break; const doc = current.heredoc; if (doc !== undefined && doc.bodyStart >= end) documents.set(doc.bodyStart, doc.end); for (const child of current.namedChildren) pending.push(child); } if (documents.size > 0) text += '\n' + [...documents] .sort(([a], [b]) => a - b) .map(([begin, stop]) => original.slice(begin, stop)) .join(''); return text; } /** Parse one stored definition inside the caller's owned parse scope. */ export function parseFunction(source, parse) { const nodes = parse(source).namedChildren.filter((node) => node.type !== NT.COMMENT); let node = nodes.length === 1 ? nodes[0] : undefined; if (node?.type === NT.REDIRECTED_STATEMENT) node = node.namedChildren[0]; if (node?.type !== NT.FUNCTION_DEFINITION) { throw new Error('stored function must contain one definition'); } const body = getFunctionBody(node); if (body === null) throw new Error('function definition has no body'); return body; } export function getFunctionBody(node) { const body = node.childForFieldName?.('body') ?? node.namedChildren.find((c) => c.type === NT.COMPOUND_STATEMENT); if (body === undefined) return null; const redirects = getFunctionRedirects(node); if (redirects.length === 0) { return body.type === NT.COMPOUND_STATEMENT ? [...body.namedChildren] : [body]; } const parts = [body, ...redirects]; return [ { type: NT.REDIRECTED_STATEMENT, text: node.text, children: parts, namedChildren: parts, parent: null, nextSibling: null, ...(node.id === undefined ? {} : { id: node.id }), ...(node.startIndex === undefined ? {} : { startIndex: node.startIndex }), ...(node.endIndex === undefined ? {} : { endIndex: node.endIndex }), ...(node.startPosition === undefined ? {} : { startPosition: node.startPosition }), ...(node.endPosition === undefined ? {} : { endPosition: node.endPosition }), }, ]; } /** * What an unquoted plain word parses as: the only words bash checks for an * alias, a name of digits included. Mirrors Python's ALIAS_WORD_TYPES. */ export const ALIAS_WORD_TYPES = new Set([NT.WORD, NT.NUMBER]); /** * A command with its alias words replaced as bash reads it, and the aliases * being expanded at each unit of the result. An insertion carries the * replaced word's guards and its own name, so no alias expands inside its own * text, while the words around it keep theirs. A value ending in a blank * checks the command's next word, the next child of the command, which an * unquoted word must be. Mirrors Python's alias_rewrite. */ export function aliasRewrite(node, head, value, guards) { const source = getText(node); const base = node.startIndex ?? 0; let at = (head.startIndex ?? 0) - base; let end = (head.endIndex ?? 0) - base; let name = getText(head); const seen = new Set(); const parts = []; const owners = []; let cursor = 0; while (!seen.has(name)) { const blocked = guards[at]; if (blocked === undefined) throw new Error('alias word has no source'); const text = value(name, blocked); if (text === null) break; seen.add(name); parts.push(source.slice(cursor, at), text); for (let i = cursor; i < at; i++) owners.push(guards[i] ?? new Set()); const guard = new Set([...blocked, name]); for (let units = text.length; units > 0; units--) owners.push(guard); cursor = end; if (!text.endsWith(' ') && !text.endsWith('\t')) break; const word = node.children.find((c) => (c.startIndex ?? 0) - base >= cursor); if (word === undefined || !ALIAS_WORD_TYPES.has(word.type)) break; name = getText(word); at = (word.startIndex ?? 0) - base; end = (word.endIndex ?? 0) - base; } if (seen.size === 0) return null; parts.push(source.slice(cursor)); for (let i = cursor; i < guards.length; i++) owners.push(guards[i] ?? new Set()); return [parts.join(''), owners]; } //# sourceMappingURL=helpers.js.map