UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

435 lines • 19.6 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 { lineSignal } from "../../context/session_context.js"; import { mergeSignals } from "../../utils/abort.js"; import { childContext } from "../evaluation.js"; import { CallStack } from "../../shell/call_stack.js"; import { quotedParts } from "../../shell/helpers.js"; import { NodeType as NT } from "../../shell/types.js"; import { landedArith } from "../session/elements.js"; import { markEscapedGlobs, markGlobs, unmarkGlobs } from "../../utils/glob_walk.js"; import { expandTilde } from "../../utils/path.js"; import { homeDir } from "../session/shell_dirs.js"; import { splitBacktickRegion } from "../../shell/backticks.js"; import { ArithError, BadSubstitution, ReadonlyVariableError, named } from "../../shell/errors.js"; import { decodeAnsiC, unescapeDquoted, unescapeUnquoted } from "../../shell/escapes.js"; import { ARITH_DELIMITERS, ARITH_OPERATORS } from "./constants.js"; import { scanParameter } from "../../shell/parameter.js"; import { joinChunks, valuePiece } from "./fields.js"; import { piece } from "./types.js"; import { expandBraces, isAtSplat, landArithWrites, parameterChunks } from "./variable.js"; import { recordStatus } from "../executor/statement.js"; import { decodeText } from "../../shell/bytes.js"; // Whitespace the parser folds into an expansion's opening token. // Inside a double-quoted string, a run of whitespace between two // expansions is not emitted as string content: it lands inside the // following node's extent, so `"$a $(b)"` yields a command substitution // whose text is `" $(b)"`. Every expansion branch has to re-emit it or // the two values run together. Unquoted words do not fold, so the // prefix is empty there and this stays a no-op. export function foldedWhitespace(node) { const raw = node.text; return raw.slice(0, raw.length - raw.trimStart().length); } /** * Expand a backtick region, one nested line per pair. `offset` is where * `raw` (the region's text, the folded prefix stripped) starts in the * node's text. */ async function expandBacktickRegion(raw, context, executeFn, node, offset, callStack) { let out = ''; for (const segment of splitBacktickRegion(raw)) { if (!segment.command) { out += segment.text; continue; } // The command is its own place on the line: the span within the node // says where it stands. const io = await childLine(context, executeFn, segment.text, node, callStack, [ offset + segment.start, offset + segment.end, ]); out += decodeText(await io.materializeStdout()).replace(/\n+$/, ''); context.frame.diagnostics.push(await io.materializeStderr()); context.frame.cmdsubSeq += 1; context.frame.cmdsubStatus = io.exitCode; recordStatus(context.session, io.exitCode, true); } return out; } // Unquoted-heredoc escapes: \$, \`, \\, \<newline> only. // Unlike double quotes, \" stays literal in heredoc bodies. /** * Run a substitution's line in a child shell. * * The evaluator isolates the child shell, except for Bash's `$(< file)` * optimization, whose filename expands in the parent. It decides from * a fresh parse of the body, including each pair in a backtick region. * The line reaches the executor unwrapped, under the node that named it, * so the pass places its commands where they were typed rather than * under a subshell of their own. The child runs on a copy of the caller's * frames: inside a function it reads the function's `$1`, `return` ends it, * and so does a `break` from a loop the caller is in. `span` is the pair's * span within the node, for a backtick region holding several. */ export async function childLine(context, executeFn, text, node, callStack, span, options = {}) { const session = context.session; return executeFn(text, { ...options, sessionId: session.sessionId, context, node, substitution: true, callStack: (callStack ?? new CallStack()).fork(true, false), ...(span === undefined ? {} : { span }), }); } // Reconstruct arithmetic expression text for the shared evaluator. // `$`-expansions substitute textually (bash performs expansions before // arithmetic evaluation), while bare variable names stay as names so the // evaluator can resolve and assign them (`$(( y = 3 ))` needs `y`, not // its value). /** * Reconstruct arithmetic expression text for the shared evaluator. A bad * substitution names the expression as written. */ export async function expandArith(tsNode, context, executeFn, callStack, view) { return named(arithInside(tsNode), arithText(tsNode, context, executeFn, callStack, view)); } function arithInside(tsNode) { const text = tsNode.text.trimStart(); for (const [opener, closer] of [ ['$((', '))'], ['((', '))'], ['$[', ']'], ]) { if (text.startsWith(opener) && text.endsWith(closer)) return text.slice(opener.length, -closer.length); } return text; } async function arithText(tsNode, context, executeFn, callStack, view) { const parts = []; const base = tsNode.startIndex ?? 0; let end = 0; for (const child of tsNode.children) { const start = (child.startIndex ?? base + end) - base; parts.push(tsNode.text.slice(end, start)); end = (child.endIndex ?? base + start + child.text.length) - base; if (ARITH_DELIMITERS.has(child.type)) continue; if (child.type === NT.BINARY_EXPRESSION || child.type === NT.UNARY_EXPRESSION || child.type === NT.PARENTHESIZED_EXPRESSION || child.type === NT.TERNARY_EXPRESSION || child.type === NT.POSTFIX_EXPRESSION || child.type === NT.CONCATENATION) { parts.push(await arithText(child, context, executeFn, callStack, view)); } else if (child.type === 'subscript') { parts.push(await arithSubscript(child, context, executeFn, callStack, view)); } else if (ARITH_OPERATORS.has(child.type) || child.type === NT.NUMBER || child.type === NT.VARIABLE_NAME || child.type === NT.WORD) { parts.push(child.text); } else { parts.push(await expandNode(child, context, executeFn, callStack, view)); } } parts.push(tsNode.text.slice(end)); return parts.join(''); } /** * Reconstruct one element reference for the arithmetic tokenizer. * * The subscript's `$`-expansions substitute here, since bash expands * the whole expression text before evaluating it, while a literal * interior rides verbatim: for an associative array the text *is* the * key (`m[k]` reads the key `k` even when a variable `k` exists), and * for an indexed one the evaluator's resolver still gets the * arithmetic spelling. */ async function arithSubscript(subNode, context, executeFn, callStack, view) { let name = ''; const inner = []; for (const sc of subNode.namedChildren) { if (sc.type === NT.VARIABLE_NAME && name === '') { name = sc.text; } else { inner.push(sc); } } const raw = subNode.text.slice(name.length + 1, -1); if (!/[$'"`]/.test(raw)) return `${name}[${raw}]`; const parts = []; for (const sc of inner) { if (sc.type === NT.SIMPLE_EXPANSION || sc.type === NT.EXPANSION || sc.type === NT.COMMAND_SUBSTITUTION || sc.type === NT.STRING || sc.type === NT.RAW_STRING || sc.type === NT.ANSI_C_STRING || sc.type === NT.TRANSLATED_STRING || sc.type === NT.CONCATENATION) { parts.push(await expandNode(sc, context, executeFn, callStack, view)); } else { parts.push(sc.text); } } return `${name}[${parts.join('')}]`; } // Expand a parsed node to the string it stands for. export async function expandNode(tsNode, context, executeFn, callStack = null, view) { return unmarkGlobs(await expandNodeMarked(tsNode, context, executeFn, callStack, view)); } /** * Expand a node, marking the glob characters quoting made literal. * * Same string as `expandNode`, except that a glob character quoting * neutralized travels under its own mark. The node is read where no * field splitting happens, so a splat reads as its elements joined * (`$@` on a space, `$*` on IFS's first character). */ export async function expandNodeMarked(tsNode, context, executeFn, callStack = null, view) { return joinChunks(await expandChunks(tsNode, context, executeFn, callStack, view)); } /** * Expand a node to the pieces field splitting reads. * * What an unquoted expansion produces splits on IFS, and what quoting * protects does not; a splat's elements are separate fields. * `splitFields` turns the pieces into words and `joinChunks` into the * one string a context without splitting reads. `quoted` says whether * the node sits inside double quotes. A bad substitution leaving it is * renamed after the node, unless a boundary inside named it for good. */ export async function expandChunks(tsNode, context, executeFn, callStack = null, view, quoted = false) { try { return await nodeChunks(tsNode, context, executeFn, callStack, view, quoted); } catch (err) { if (err instanceof BadSubstitution) throw err.within(tsNode.text.trimStart()); throw err; } } async function nodeChunks(tsNode, context, executeFn, callStack, view, quoted) { const session = context.session; const ntype = tsNode.type; if (ntype === NT.WORD) { return [piece(expandTilde(unescapeUnquoted(markEscapedGlobs(tsNode.text)), homeDir(session)))]; } if (ntype === NT.COMMAND_NAME) { // The name is a word like any other: $CMD, "quoted", $(sub) all // expand. A bare word has one named child (or none) and falls // through to its own expansion rule. const child = tsNode.namedChildren[0]; if (child !== undefined) return expandChunks(child, context, executeFn, callStack, view); return [piece(tsNode.text)]; } if (ntype === NT.SIMPLE_EXPANSION) { const prefix = foldedWhitespace(tsNode); const raw = tsNode.text.slice(prefix.length); const lead = prefix !== '' ? [piece(prefix)] : []; const ref = scanParameter(raw, 0); if (ref === null) return [...lead, piece(quoted ? markGlobs(raw) : raw)]; const [name, end] = ref; const tail = raw.slice(end); return [ ...lead, ...(await parameterChunks(name, session, callStack, quoted)), ...(tail !== '' ? [piece(quoted ? markGlobs(tail) : tail)] : []), ]; } if (ntype === NT.EXPANSION) { const prefix = foldedWhitespace(tsNode); const expandChild = (c, inQuotes) => expandChunks(c, context, executeFn, callStack, view, inQuotes); const chunks = await expandBraces(tsNode, session, callStack, expandChild, view, quoted, mergeSignals(context.frame.abortSignal, lineSignal(session))); return prefix !== '' ? [piece(prefix), ...chunks] : chunks; } if (ntype === NT.PROCESS_SUBSTITUTION && context.frame.processSub !== null) { // The child shell is taken where the word expands, its function's // locals included, so the substitution sees the shell as it stood // then while the command naming it goes on. const parent = childContext(context); return [ piece(await context.frame.processSub(tsNode, (text, options) => childLine(parent, executeFn, text, tsNode, callStack, undefined, options))), ]; } if (ntype === NT.COMMAND_SUBSTITUTION || ntype === NT.ARITHMETIC_EXPANSION) { const text = await substitution(tsNode, context, executeFn, callStack, view); const prefix = foldedWhitespace(tsNode); return [...(prefix !== '' ? [piece(prefix)] : []), valuePiece(text, quoted)]; } if (ntype === NT.CONCATENATION) { // Each piece carries its own quoting, which is the whole reason // marks are per character: `'*'?.txt` joins a marked star to a live // question mark and still globs, on the `?` alone. A $"..." arrives // as an anonymous `$` token followed by the string node; the `$` is // the translation marker, not text. A bare trailing `$` (a$) has no // string after it and stays literal. const chunks = []; const children = tsNode.children; for (let position = 0; position < children.length; position += 1) { const child = children[position]; if (child === undefined) continue; if (child.type === '$' && children[position + 1]?.type === NT.STRING) continue; for (const c of await expandChunks(child, context, executeFn, callStack, view)) chunks.push(c); } return chunks; } if (ntype === NT.STRING) return stringChunks(tsNode, context, executeFn, callStack, view); if (ntype === NT.TRANSLATED_STRING) { // $"..." asks for a locale translation; no message catalog is ever // loaded, so the translation is the identity and the word keeps // plain double-quote semantics. for (const child of tsNode.namedChildren) { if (child.type === NT.STRING) { return stringChunks(child, context, executeFn, callStack, view); } } return [piece('')]; } const text = await literalNode(tsNode, context, executeFn, callStack, view); return [piece(quoted ? markGlobs(text) : text)]; } /** * A double-quoted string's pieces, one field unless a splat splits it. * * Everything the quotes enclose is literal, the text and every value * alike: `"$p"?.txt` globs on the `?` alone. The quotes open a field * even around nothing (`""`), except that a `$@`-style splat over no * elements, with no other text, is no field at all: with no parameters * `"$@"` and `"$u$@"` are nothing, while one empty parameter is one * empty word. Only the element count decides that, never the rendered * text. A bad substitution names what the quotes enclose, or the whole * document of a heredoc the string stands for. */ async function stringChunks(node, context, executeFn, callStack, view) { const chunks = [piece('')]; let splat = false; let yielded = false; const inside = node.parent?.heredoc?.body ?? node.text.slice(1, -1); for (const part of quotedParts(node)) { if (typeof part === 'string') { chunks.push(piece(markGlobs(part))); continue; } const pieces = await named(inside, expandChunks(part, context, executeFn, callStack, view, true)); if (isAtSplat(part)) { splat = true; yielded = yielded || pieces.length > 0; } for (const c of pieces) chunks.push(c); } if (splat && !yielded && joinChunks(chunks) === '') return []; return chunks; } /** * An arithmetic expansion's value. The write-back goes through the session * view, so a preSession rule governs `$((X=5))` exactly as it governs * `X=5`; an error unwinds as its `signal`. */ async function arithValue(session, view, expr) { try { return (await landedArith(session, view ?? null, expr, landArithWrites)).toString(); } catch (err) { if (err instanceof ArithError || err instanceof ReadonlyVariableError) throw err.signal(); throw err; } } /** A command substitution's output or an arithmetic expansion's value. */ async function substitution(tsNode, context, executeFn, callStack, view) { const session = context.session; const prefix = foldedWhitespace(tsNode); if (tsNode.type === NT.ARITHMETIC_EXPANSION) { return arithValue(session, view, await expandArith(tsNode, context, executeFn, callStack, view)); } const rawSub = (tsNode.inlined ?? tsNode.sourceText ?? tsNode.text).slice(prefix.length); if (rawSub.startsWith('`') && rawSub.endsWith('`')) { // A backtick body loses its backslashes before it is parsed, so it is // lexed again here (see splitBacktickRegion). return expandBacktickRegion(rawSub, context, executeFn, tsNode, prefix.length, callStack); } // The whole body goes to the evaluator: bash substitutes the full // statement list, and picking child nodes dropped every statement // after a `;` and every non-command statement (declarations, // assignments, control flow). const inner = rawSub.slice(2, -1); if (inner.trim() === '') return ''; // The substitution names its own node: the nested line's commands // stand under it, which is where the pass placed them. const io = await childLine(context, executeFn, inner, tsNode, callStack); const text = decodeText(await io.materializeStdout()).replace(/\n+$/, ''); // Record the substitution's status: an assignment-only statement // whose value ran substitutions reports the last one's status as // its own (see assignmentStatus), and `$?` reads it in the words that // follow (`false; echo $(true) $?` prints 0). context.frame.diagnostics.push(await io.materializeStderr()); context.frame.cmdsubSeq += 1; context.frame.cmdsubStatus = io.exitCode; recordStatus(context.session, io.exitCode, true); return text; } /** The text of a node no expansion splits: quoted words and the rest. */ async function literalNode(tsNode, context, executeFn, callStack, view) { const ntype = tsNode.type; if (ntype === NT.NUMBER) return tsNode.text; if (ntype === NT.STRING_CONTENT) { return unescapeDquoted(tsNode.text); } if (ntype === NT.RAW_STRING) { const raw = tsNode.text; return markGlobs(raw.slice(1, -1)); } if (ntype === NT.ANSI_C_STRING) { const raw = tsNode.text; return markGlobs(decodeAnsiC(raw.slice(2, -1))); } if (ntype === NT.VARIABLE_ASSIGNMENT) { const raw = tsNode.text; if (raw.includes('=')) { const eq = raw.indexOf('='); const key = raw.slice(0, eq); const valPart = raw.slice(eq + 1); const valNodes = tsNode.namedChildren.filter((c) => c.type !== NT.VARIABLE_NAME); if (valNodes.length > 0 && valNodes[0] !== undefined) { const expanded = await expandNode(valNodes[0], context, executeFn, callStack, view); return `${key}=${expanded}`; } return `${key}=${valPart}`; } return raw; } return tsNode.text; } //# sourceMappingURL=node.js.map