UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

525 lines • 22.9 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 { decodeAnsiC } from "./escapes.js"; import { ALIAS_WORD_TYPES, REDIRECT_NODE_TYPES, aliasRewrite, getCaseItems, getForParts, getFunctionRedirects, getText, parseFunction, } from "./helpers.js"; import { BASH_KEYWORDS } from "./parse/constants.js"; import { NodeType as NT } from "./types.js"; import { singleQuote } from "../utils/quote.js"; const INDENT = ' '; const CONTINUATION = /\\\n[ \t]*/g; // A name bash would read as a reserved word is printed after `function`. const RESERVED = new Set([ ...BASH_KEYWORDS, '!', '{', '}', '[[', ']]', 'time', 'coproc', ]); /** Render a portable definition without keeping its parsed tree. */ export function storedFunctionText(name, source, parser, aliases = new Map()) { if (parser === undefined) throw new Error('function rendering requires a parse scope'); const scope = parser.fork(); const names = new Set(aliases.keys()); const parse = (line) => scope.parse(line, names); try { return functionText(name, parseFunction(source, parse), new Aliases(aliases, parse)); } finally { scope.release(); } } /** * A function as `declare -f` and `type` print it. bash prints its own * rendering of the parsed definition, not the text that was typed: a * body of statements one per line with four-space indents, * `;` between them, `elif` as an `if` nested in `else`, redirects respelled * after the words (`>&2` is `1>&2`), `$'...'` decoded into single quotes, * every substitution's command printed the same way, and each alias it read * the definition with expanded. Mirrors Python's function_text. */ export function functionText(name, body, aliases = null) { return new Printer(true, aliases).definition(name, definitionOf(body), ''); } /** The aliases a body was read with, and the parser that reads an expansion again. */ class Aliases { values; parse; constructor(values, parse) { this.values = values; this.parse = parse; } } function definitionOf(body) { let node = body[0]; if (node === undefined) throw new Error('function: empty body'); if (node.parent == null && node.type === NT.REDIRECTED_STATEMENT) node = node.children[0] ?? node; let parent = node.parent ?? null; while (parent !== null && parent.type !== NT.FUNCTION_DEFINITION) parent = parent.parent ?? null; return parent ?? node; } function start(node) { return node.startIndex ?? 0; } function end(node) { return node.endIndex ?? 0; } function stripContinuations(text) { return text.replace(CONTINUATION, ''); } /** * One rendering: inside a function body or a substitution's own top level, * which prints its `;` and newline separators as typed. Heredoc bodies wait * for the end of the line their operator is on. */ class Printer { inFunction; aliases; owners; deferred = []; constructor(inFunction, aliases = null, // The aliases being expanded at each unit of the tree printed, for one // an alias rewrote. owners = null) { this.inFunction = inFunction; this.aliases = aliases; this.owners = owners; } /** A command named by an alias, printed as what bash read it as. Mirrors Python's _expanded. */ expanded(node, indent) { if (this.aliases === null) return null; const head = node.childForFieldName?.('name')?.namedChildren[0]; if (head === undefined || !ALIAS_WORD_TYPES.has(head.type)) return null; const { values, parse } = this.aliases; const start = node.startIndex ?? 0; const guards = this.owners?.slice(start, node.endIndex ?? start) ?? Array(node.text.length).fill(new Set()); const rewrite = aliasRewrite(node, head, (name, blocked) => (blocked.has(name) ? null : (values.get(name) ?? null)), guards); if (rewrite === null) return null; const [text, owners] = rewrite; const root = parse(text); const mapped = [...owners, new Set()]; const printer = new Printer(this.inFunction, this.aliases, Array.from(root.offsets ?? [], (i) => mapped[i] ?? new Set())); const out = printer.statements(root.children, indent, false); this.deferred.push(...printer.deferred); return out; } definition(name, node, indent) { const body = node.childForFieldName?.('body') ?? null; const inner = indent + INDENT; const text = body === null ? '' : body.type === NT.COMPOUND_STATEMENT ? this.statements(body.children, inner, false) : this.command(body, inner); const keyword = indent !== '' || RESERVED.has(name); const head = `${keyword ? 'function ' : ''}${name} () \n`; let out = `${head}${indent}{ \n${inner}${text}\n${indent}}${this.redirects(getFunctionRedirects(node))}`; // The definition's own heredocs follow its line, as bash prints them; // a nested definition's wait for the statement it ends. if (indent === '' && this.deferred.length > 0) { out += '\n' + this.deferred.join(''); this.deferred = []; } return out; } /** * A list of statements: each on its own line under `indent`, `;` between * them, after the last one too when `trailing` (the body of an `if` or a * loop); `&` keeps the next on its line. */ statements(children, indent, trailing) { const parts = []; const background = new Set(); const newlineAfter = new Set(); for (const child of children) { if (child.type === '&' && parts.length > 0) background.add(parts.length - 1); else if (child.isNamed === true && child.type !== NT.COMMENT) { const previous = parts[parts.length - 1]; if (previous !== undefined && newlineBetween(previous, child)) newlineAfter.add(parts.length - 1); parts.push(child); } } let out = ''; parts.forEach((part, index) => { out += this.command(part, indent); const heredoc = this.deferred.length > 0; if (heredoc) { out += `\n${this.deferred.join('')}`; this.deferred = []; } const last = index === parts.length - 1; if (background.has(index)) out += last ? ' &' : ' & '; else if (heredoc) out += last ? '' : `\n${indent}`; else if (!last) out += this.inFunction ? `;\n${indent}` : newlineAfter.has(index) ? '\n' : '; '; else if (trailing) out += ';'; }); return out; } /** One statement, its compound bodies indented under `indent`. */ command(node, indent) { const kind = node.type; const named = node.namedChildren; if (kind === NT.REDIRECTED_STATEMENT) { const body = named[0]; if (body === undefined || REDIRECT_NODE_TYPES.has(body.type)) return this.redirects(named).trimStart(); const redirects = named.slice(1).filter((c) => REDIRECT_NODE_TYPES.has(c.type)); if (body.type === NT.FUNCTION_DEFINITION) return this.definition(getText(body.namedChildren[0] ?? body), body, indent); return this.command(body, indent) + this.redirects(redirects); } if (kind === NT.COMMAND) { const expanded = this.expanded(node, indent); if (expanded !== null) return expanded; const words = named.filter((c) => !REDIRECT_NODE_TYPES.has(c.type)).map((c) => this.word(c)); return words.join(' ') + this.redirects(named.filter((c) => REDIRECT_NODE_TYPES.has(c.type))); } if (kind === NT.PIPELINE) return this.pipeline(node, indent); if (kind === NT.LIST) { const op = node.children.find((c) => c.isNamed !== true)?.type ?? '&&'; return `${this.command(named[0] ?? node, indent)} ${op} ${this.command(named[1] ?? node, indent)}`; } if (kind === 'negated_command' || kind === 'timed_statement') return this.prefixed(node, indent); if (kind === NT.SUBSHELL) return `( ${this.statements(node.children, indent, false)} )`; if (kind === 'test_command' && getText(node).startsWith('[[')) return `[[ ${this.condition(named[0] ?? node)} ]]`; if (kind === NT.COMPOUND_STATEMENT) { if (node.children[0]?.type === '((') return stripContinuations(getText(node)); const inner = indent + INDENT; return `{ \n${inner}${this.statements(node.children, inner, false)}\n${indent}}`; } if (kind === NT.IF_STATEMENT) return this.ifStatement(node, indent); if (kind === NT.WHILE_STATEMENT || kind === 'until_statement') return this.loop(node, indent); if (kind === NT.FOR_STATEMENT) return this.forStatement(node, indent); if (kind === 'c_style_for_statement') return this.cfor(node, indent); if (kind === NT.CASE_STATEMENT) return this.caseStatement(node, indent); if (kind === NT.FUNCTION_DEFINITION) return this.definition(getText(named[0] ?? node), node, indent); if (kind === 'variable_assignments' || kind === 'declaration_command' || kind === 'unset_command') { const words = named.filter((c) => !REDIRECT_NODE_TYPES.has(c.type)).map((c) => this.word(c)); const keyword = node.children.find((c) => c.isNamed !== true); if (keyword !== undefined) words.unshift(getText(keyword)); return words.join(' ') + this.redirects(named.filter((c) => REDIRECT_NODE_TYPES.has(c.type))); } return this.word(node); } /** * A `[[ ]]` expression as bash prints its parsed form: one space around * each operator, and a bare operand tested with `-n`. */ condition(node) { const kind = node.type; const named = node.namedChildren; const [left, operator, right] = node.children; if (kind === 'binary_expression' && node.children.length === 3 && left !== undefined && operator !== undefined && right !== undefined) { const op = getText(operator); if (op === '&&' || op === '||') return `${this.condition(left)} ${op} ${this.condition(right)}`; return `${this.word(left)} ${op} ${this.word(right)}`; } const operand = named[named.length - 1]; const opNode = node.children[0]; if (kind === 'unary_expression' && operand !== undefined && opNode !== undefined) { const op = getText(opNode); if (op === '!' && end(opNode) < start(operand)) return `! ${this.condition(operand)}`; if (op !== '!') return `${op} ${this.word(operand)}`; } const first = named[0]; if (kind === 'parenthesized_expression' && first !== undefined) return `( ${this.condition(first)} )`; return `-n ${this.word(node)}`; } pipeline(node, indent) { let out = ''; for (const child of node.children) { if (child.type === '|') out += ' |'; else if (child.type === '|&') out += ' 2>&1 |'; else if (child.isNamed === true) { if (out !== '') { out += this.deferred.length > 0 ? `\n${this.deferred.join('')} ` : ' '; this.deferred = []; } out += this.command(child, indent); } } return out; } ifStatement(node, indent) { const branches = []; let otherwise = null; let condition = []; let body = []; let target = condition; for (const child of node.children) { if (child.type === 'if' || child.type === 'elif') target = condition; else if (child.type === 'then') target = body; else if (child.type === NT.ELIF_CLAUSE || child.type === NT.ELSE_CLAUSE || child.type === 'fi') { if (condition.length > 0 || body.length > 0) { branches.push([condition, body]); condition = []; body = []; } if (child.type === NT.ELIF_CLAUSE) { target = condition; for (const part of child.children) { if (part.type === 'elif') target = condition; else if (part.type === 'then') target = body; else target.push(part); } branches.push([condition, body]); condition = []; body = []; } else if (child.type === NT.ELSE_CLAUSE) { otherwise = child.children.filter((part) => part.type !== 'else'); } } else target.push(child); } return this.branches(branches, otherwise, indent); } branches(branches, otherwise, indent) { const [condition, body] = branches[0] ?? [[], []]; const inner = indent + INDENT; let out = `if ${this.statements(condition, indent, false)}; then\n${inner}${this.statements(body, inner, true)}\n`; if (branches.length > 1) out += `${indent}else\n${inner}${this.branches(branches.slice(1), otherwise, inner)};\n`; else if (otherwise !== null) out += `${indent}else\n${inner}${this.statements(otherwise, inner, true)}\n`; return `${out}${indent}fi`; } loop(node, indent) { const condition = node.children.filter((c) => c.type !== 'while' && c.type !== 'until' && c.type !== NT.DO_GROUP); const group = node.children.find((c) => c.type === NT.DO_GROUP) ?? null; const head = node.children[0]?.type ?? 'while'; return `${head} ${this.statements(condition, indent, false)}; do${this.group(group, indent)}`; } group(group, indent) { const children = (group?.children ?? []).filter((c) => c.type !== 'do' && c.type !== 'done'); const inner = indent + INDENT; return `\n${inner}${this.statements(children, inner, true)}\n${indent}done`; } /** * A pipeline under `time` and `!` words, as bash prints it: `time` once * (`-p` if any has it), then `!` when an odd number of them negate it, * then the pipeline, which may be empty. Mirrors Python's _prefixed. */ prefixed(node, indent) { let timed = false; let portable = false; let negated = false; let inner = node; while (inner !== undefined && (inner.type === 'negated_command' || inner.type === 'timed_statement')) { if (inner.type === 'timed_statement') { timed = true; portable ||= inner.children.some((c) => c.type === '-p'); } else { negated = !negated; } inner = inner.namedChildren[0]; } const prefix = (timed ? (portable ? 'time -p ' : 'time ') : '') + (negated ? '! ' : ''); return prefix + (inner === undefined ? '' : this.command(inner, indent)); } forStatement(node, indent) { const [variable, values] = getForParts(node); const words = values === null ? '"$@"' : values.map((v) => this.word(v)).join(' '); const group = node.children.find((c) => c.type === NT.DO_GROUP) ?? null; const head = node.children[0]?.type ?? 'for'; return `${head} ${variable} in ${words};\n${indent}do${this.group(group, indent)}`; } cfor(node, indent) { const source = node.text; const base = start(node); const slots = []; let begin = null; for (const child of node.children) { if (child.type !== '((' && child.type !== ';' && child.type !== '))') continue; if (begin === null && child.type !== '((') continue; if (begin !== null) { const text = source.slice(begin - base, start(child) - base).trimStart(); slots.push(text === '' ? '1' : text); } if (child.type === '))') break; begin = end(child); } const group = node.children.find((c) => c.type === NT.DO_GROUP) ?? null; return `for ((${slots.join('; ')}))\n${indent}do${this.group(group, indent)}`; } caseStatement(node, indent) { const itemIndent = indent + INDENT; const bodyIndent = itemIndent + INDENT; let out = `case ${this.word(node.namedChildren[0] ?? node)} in \n`; const items = node.namedChildren.filter((c) => c.type === NT.CASE_ITEM); getCaseItems(node).forEach(([patterns, body], index) => { const item = items[index]; const text = this.statements(body, bodyIndent, false); out += `${itemIndent}${patterns.map((p) => this.word(p)).join(' | ')})\n` + `${text === '' ? '' : bodyIndent + text}\n` + `${itemIndent}${item === undefined ? ';;' : terminator(node, item)}\n`; }); return `${out}${indent}esac`; } redirects(nodes) { return nodes .filter((n) => REDIRECT_NODE_TYPES.has(n.type)) .map((n) => ` ${this.redirect(n)}`) .join(''); } /** * A redirect as bash respells it: a duplication or a close names its * descriptor (`1>&2`), a file one only a descriptor other than the * operator's own (`> f`, `2> f`), and a heredoc's body waits for the end of * the line. */ redirect(node) { const fdNode = node.children.find((c) => c.type === NT.FILE_DESCRIPTOR); const fd = fdNode === undefined ? null : getText(fdNode); if (node.heredoc !== undefined) return this.heredoc(fd, node.heredoc); const text = getText(node); const opNode = node.children.find((c) => c.isNamed !== true && c.type !== '(' && c.type !== ')'); const op = opNode === undefined ? '' : getText(opNode); const target = node.namedChildren.find((c) => c.type !== NT.FILE_DESCRIPTOR); const word = target === undefined ? '' : this.word(target); if (text.replace(/^\d+/, '').startsWith('<<<')) return `${fd ?? ''}<<< ${word}`; if (op === '>&' || op === '<&' || op === '>&-' || op === '<&-') { if (target !== undefined && target.type !== NT.NUMBER && op === '>&') return `&> ${word}`; return `${fd ?? (op.startsWith('>') ? '1' : '0')}${op}${word}`; } if (op === '<>') return `${fd ?? '0'}<> ${word}`; if (op === '&>' || op === '&>>') return `${op} ${word}`; const fallback = op === '<' ? '0' : '1'; const value = fd?.replace(/^0+(?=\d)/, '') ?? null; const shown = value !== null && value !== fallback ? (fd ?? '') : ''; return `${shown}${op} ${word}`; } heredoc(fd, document) { this.deferred.push(`${document.body}${document.delimiter}\n`); const shown = fd !== null && /[1-9]/.test(fd) ? fd : ''; const operator = document.dash ? '<<-' : '<<'; return shown + operator + (document.quoted ? singleQuote(document.delimiter) : document.word); } /** * A word as typed, but for what bash rewrites: `$'...'` decoded into single * quotes, `$"..."` as a plain string, a substitution's command printed * afresh, and a line continuation dropped. */ word(node) { const kind = node.type; const text = getText(node); if (kind === NT.ANSI_C_STRING) return singleQuote(decodeAnsiC(text.slice(2, -1))); if (kind === 'translated_string') return text.slice(1); if (kind === NT.COMMAND_SUBSTITUTION && text.startsWith('$(')) { const inner = node.children.filter((c) => c.type !== '$(' && c.type !== ')'); return `$(${new Printer(false).statements(inner, '', false)})`; } if (node.children.length === 0) return stripContinuations(text); let out = ''; for (const part of parts(node)) { out += typeof part === 'string' ? stripContinuations(part) : this.word(part); } return out; } } function newlineBetween(left, right) { const parent = left.parent ?? null; if (parent === null) return false; const between = parent.text.slice(end(left) - start(parent), start(right) - start(parent)); return between.includes('\n') && !between.includes(';'); } // As typed: the parser spells a last arm's `;;&` as `;;`. function terminator(caseNode, item) { const token = item.children.find((c) => c.type === ';;' || c.type === ';&' || c.type === ';;&'); if (token === undefined) return ';;'; const text = getText(token); const after = caseNode.text.slice(end(token) - start(caseNode))[0]; return text === ';;' && after === '&' ? ';;&' : text; } function parts(node) { const source = node.text; const base = start(node); let at = base; const out = []; for (const child of node.children) { if (start(child) > at) out.push(source.slice(at - base, start(child) - base)); at = end(child); out.push(child); } if (at < end(node)) out.push(source.slice(at - base)); return out; } //# sourceMappingURL=printer.js.map