@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
1,020 lines • 38.2 kB
JavaScript
// ========= 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