@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
652 lines • 24.8 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 { ARITH_ASSIGN_OPS, ARITH_BLANKS, ARITH_LITERAL, ARITH_MAX_DEPTH, ARITH_NAME, ARITH_OPERATOR, ARITH_PRECEDENCE, ARITH_UNARY_OPS, } from "./constants.js";
import { ArithError, ReadonlyVariableError, UnboundVariable } from "./errors.js";
/**
* Index of the `]` closing the `[` at `start`, quote-aware, or null when
* it never closes. Quotes matter because an associative key may hold a
* bracket (`m["a]b"]`); nesting matters because an indexed subscript may
* hold another reference (`a[b[0]]`).
*/
function matchingBracket(expr, start) {
let depth = 0;
let i = start;
while (i < expr.length) {
const ch = expr[i];
if (ch === '"' || ch === "'") {
const close = expr.indexOf(ch, i + 1);
if (close === -1)
return null;
i = close + 1;
continue;
}
if (ch === '[')
depth++;
else if (ch === ']') {
depth--;
if (depth === 0)
return i;
}
i++;
}
return null;
}
/** `value` wrapped to a signed 64-bit integer, as bash's arithmetic wraps. */
export function wrapInt64(value) {
return BigInt.asIntN(64, value);
}
/** The value of one digit of a `base#digits` constant. */
function baseDigit(ch, base) {
if (ch >= '0' && ch <= '9')
return ch.charCodeAt(0) - 48;
if (ch >= 'a' && ch <= 'z')
return ch.charCodeAt(0) - 97 + 10;
if (ch >= 'A' && ch <= 'Z')
return ch.charCodeAt(0) - 65 + (base <= 36 ? 10 : 36);
if (ch === '@')
return 62;
return 63;
}
/**
* The value of `digits` in `base` modulo 2**64, or null when one is too
* great. Kept to 64 bits as it is read, so a constant thousands of digits
* long stays linear to read.
*/
function digitsValue(digits, base) {
let value = 0n;
for (const ch of digits) {
const digit = baseDigit(ch, base);
if (digit >= base)
return null;
value = BigInt.asUintN(64, value * BigInt(base) + BigInt(digit));
}
return value;
}
/**
* `text` as a plain decimal (ASCII digits, no leading `0`, which would read
* as octal), wrapped to 64 bits; null when it is anything else and has to
* be evaluated.
*/
export function plainDecimal(text) {
const number = text.replace(/^[ \t\n]+|[ \t\n]+$/g, '');
if (!/^[1-9][0-9]*$/.test(number))
return null;
const value = digitsValue(number, 10);
return value === null ? null : wrapInt64(value);
}
/**
* The value of an integer constant, or what bash says of a bad one:
* decimal, octal after a leading `0`, hexadecimal after `0x` (bare `0x` is
* 0), or `base#digits` for a base from 2 to 64 written in decimal
* (`0x10#f` is an invalid number); the value wraps to 64 bits.
*/
function constant(text) {
let value;
const hash = text.indexOf('#');
if (hash !== -1) {
const base = constant(text.slice(0, hash));
if (typeof base === 'string')
return base;
if (text.startsWith('0'))
return 'invalid number';
if (base < 2n || base > 64n)
return 'invalid arithmetic base';
const digits = text.slice(hash + 1);
if (digits === '' || digits.startsWith('#'))
return 'invalid integer constant';
if (digits.includes('#'))
return 'invalid number';
value = digitsValue(digits, Number(base));
}
else if (text.startsWith('0x') || text.startsWith('0X')) {
value = digitsValue(text.slice(2), 16);
}
else if (text.startsWith('0')) {
value = digitsValue(text, 8);
}
else {
value = digitsValue(text, 10);
}
return value === null ? 'value too great for base' : wrapInt64(value);
}
/** The binary operators over 64-bit wrapping integers, `/`, `%` and `**` aside. */
const BINARY = {
'+': (a, b) => wrapInt64(a + b),
'-': (a, b) => wrapInt64(a - b),
'*': (a, b) => wrapInt64(a * b),
'<<': (a, b) => wrapInt64(a << (b & 63n)),
'>>': (a, b) => a >> (b & 63n),
'&': (a, b) => a & b,
'|': (a, b) => a | b,
'^': (a, b) => a ^ b,
'==': (a, b) => (a === b ? 1n : 0n),
'!=': (a, b) => (a !== b ? 1n : 0n),
'<': (a, b) => (a < b ? 1n : 0n),
'<=': (a, b) => (a <= b ? 1n : 0n),
'>': (a, b) => (a > b ? 1n : 0n),
'>=': (a, b) => (a >= b ? 1n : 0n),
};
/** `base ** exponent` modulo 2**64, by squaring. */
function power(base, exponent) {
let result = 1n;
let factor = BigInt.asUintN(64, base);
for (let e = exponent; e > 0n; e >>= 1n) {
if ((e & 1n) === 1n)
result = BigInt.asUintN(64, result * factor);
factor = BigInt.asUintN(64, factor * factor);
}
return wrapInt64(result);
}
/**
* Reads one arithmetic expression and evaluates it as it goes, as bash
* does. A token is read only when the grammar needs it, and every value
* is computed the moment its operands are, so an assignment before an
* error has already been made (`x=7, 1+` leaves x at 7) and an error names
* the text from the token the reader stood on (`tokStart`). Inside the branch
* `&&`, `||` or `?:` skips (`skip`) nothing is read from a variable or
* written to one and a zero divisor is no error, but the grammar is still
* checked, an integer constant still judged and a negative exponent still
* refused. Mirrors Python's _Reader.
*/
class Reader {
record;
text;
depth;
subscript;
pos = 0;
tokStart = 0;
kind = 'end';
tok = '';
value = 0n;
skip = 0;
constructor(record, text, depth, subscript) {
this.record = record;
this.text = text;
this.depth = depth;
this.subscript = subscript;
}
/** The error bash reports at the token the reader stands on, or at `at`. */
fail(reason, at = this.tokStart) {
return new ArithError(reason, this.text, this.text.slice(at));
}
/**
* Read the next token. `++` and `--` after a name are its postfix
* operators, before a name (blanks between allowed) its prefix ones, and
* anywhere else two signs (`1++2` is `1 + +2`). A name joined to a `[`
* takes its subscript along unread. At the end the reader stays on the
* last token, which an error then names.
*/
advance() {
const text = this.text;
let pos = this.pos;
while (pos < text.length && ARITH_BLANKS.includes(text[pos] ?? ''))
pos++;
const afterName = this.kind === 'name';
if (pos >= text.length) {
this.set(pos, 'end', '');
return;
}
this.tokStart = pos;
ARITH_NAME.lastIndex = pos;
if (ARITH_NAME.test(text)) {
let end = ARITH_NAME.lastIndex;
if (text[end] === '[') {
const close = matchingBracket(text, end);
if (close === null)
throw this.fail('bad array subscript');
end = close + 1;
}
this.set(end, 'name', text.slice(pos, end));
return;
}
ARITH_LITERAL.lastIndex = pos;
if (ARITH_LITERAL.test(text)) {
const end = ARITH_LITERAL.lastIndex;
const literal = text.slice(pos, end);
const value = constant(literal);
if (typeof value === 'string')
throw new ArithError(value, text.slice(0, end), literal);
this.value = value;
this.set(end, 'num', literal);
return;
}
ARITH_OPERATOR.lastIndex = pos;
const operator = ARITH_OPERATOR.exec(text);
if (operator === null) {
this.set(pos + 1, 'bad', text[pos] ?? '');
return;
}
let op = operator[0];
if (op === '++' || op === '--') {
if (afterName) {
this.set(pos + 2, 'post', op);
return;
}
let ahead = pos + 2;
while (ahead < text.length && ARITH_BLANKS.includes(text[ahead] ?? ''))
ahead++;
ARITH_NAME.lastIndex = ahead;
if (ARITH_NAME.test(text)) {
this.set(pos + 2, 'pre', op);
return;
}
op = op.charAt(0);
}
this.set(pos + op.length, 'op', op);
}
set(pos, kind, tok) {
this.pos = pos;
this.kind = kind;
this.tok = tok;
}
is(kind) {
return this.kind === kind;
}
at(op) {
return this.kind === 'op' && this.tok === op;
}
/** The error for a token where an operator or closer belongs. */
stray(reason) {
if (this.is('bad'))
return this.fail('syntax error: invalid arithmetic operator');
return this.fail(reason);
}
run() {
this.advance();
if (this.is('end'))
return 0n;
const value = this.comma();
if (!this.is('end'))
throw this.stray('syntax error in expression');
return value;
}
comma() {
let value = this.assign();
while (this.at(',')) {
this.advance();
value = this.assign();
}
return value;
}
assign() {
if (this.is('name')) {
const [pos, at, target] = [this.pos, this.tokStart, this.tok];
this.advance();
if (this.is('op') && ARITH_ASSIGN_OPS.has(this.tok))
return this.assignment(target, at);
this.set(pos, 'name', target);
this.tokStart = at;
}
const value = this.ternary();
if (this.is('op') && ARITH_ASSIGN_OPS.has(this.tok)) {
throw this.fail('attempted assignment to non-variable');
}
return value;
}
/**
* An assignment to `target`, the reader on its operator. bash evaluates
* a plain assignment's right side before it resolves the target's
* subscript (`x=0, a[x++]=x++` stores 0 at index 1), and a compound one
* reads its target before the right side. In a skipped branch nothing is
* read or written, but a compound one still computes over 0, so its value
* can refuse a negative exponent (`1 || 2**(x-=1)`). `at` is where the
* name starts.
*/
assignment(target, at) {
const op = this.tok;
this.advance();
const divisor = this.tokStart;
let key;
let value;
if (op === '=') {
value = this.assign();
if (this.skip > 0)
return value;
key = this.record.keyOf(target, this.depth);
}
else if (this.skip > 0) {
return this.apply(op.slice(0, -1), 0n, this.assign(), divisor);
}
else {
let current;
[key, current] = this.lookup(target, at);
value = this.apply(op.slice(0, -1), current, this.assign(), divisor);
}
this.record.writeTarget(target, key, value, this.subscript);
return value;
}
ternary() {
const cond = this.binary(1);
if (!this.at('?'))
return cond;
this.advance();
if (this.is('end') || this.at(':'))
throw this.fail('expression expected');
const taken = cond !== 0n;
if (!taken)
this.skip++;
const then = this.comma();
if (!taken)
this.skip--;
if (!this.at(':'))
throw this.stray("`:' expected for conditional expression");
this.advance();
if (this.is('end'))
throw this.fail('expression expected');
if (taken)
this.skip++;
const other = this.ternary();
if (taken)
this.skip--;
return taken ? then : other;
}
/** Binary operators binding at least as tightly as `floor`. */
binary(floor) {
let left = this.unary();
while (this.is('op')) {
const op = this.tok;
const precedence = ARITH_PRECEDENCE.get(op) ?? 0;
if (precedence < floor)
break;
this.advance();
const divisor = this.tokStart;
if (op === '&&' || op === '||') {
const skipped = (left === 0n) === (op === '&&');
if (skipped)
this.skip++;
const right = this.binary(precedence + 1);
if (skipped)
this.skip--;
const truth = op === '&&' ? left !== 0n && right !== 0n : left !== 0n || right !== 0n;
left = truth ? 1n : 0n;
continue;
}
const right = this.binary(op === '**' ? precedence : precedence + 1);
left = this.apply(op, left, right, divisor);
}
return left;
}
/**
* One binary operator over 64-bit wrapping integers; division truncates
* toward zero and `%` takes the dividend's sign, as in C. `divisor` is
* where the right operand starts, which a division by 0 names.
*/
apply(op, a, b, divisor) {
if (op === '/' || op === '%') {
if (b === 0n) {
if (this.skip > 0)
return 0n;
throw this.fail('division by 0', divisor);
}
return wrapInt64(op === '/' ? a / b : a % b);
}
if (op === '**') {
if (b < 0n)
throw this.fail('exponent less than 0');
return power(a, b);
}
return BINARY[op](a, b);
}
unary() {
if (this.is('op') && ARITH_UNARY_OPS.has(this.tok)) {
const op = this.tok;
this.advance();
const value = this.unary();
if (op === '!')
return value === 0n ? 1n : 0n;
if (op === '~')
return wrapInt64(~value);
if (op === '-')
return wrapInt64(-value);
return value;
}
if (this.is('pre')) {
const step = this.tok === '++' ? 1n : -1n;
this.advance();
const [target, at] = [this.tok, this.tokStart];
this.advance();
const value = this.step(target, step, true, at);
if (this.is('post'))
throw this.fail(`${this.tok}: assignment requires lvalue`);
return value;
}
return this.primary();
}
/**
* `++` or `--` on `target`: the new value before it, the old one after.
* bash makes the write once it has read the token after the operand: a
* prefix one after the token past the name (`++x 08` refuses the
* constant and leaves x), a postfix one before the token past the
* operator (`x++ 08` leaves x stepped). In a skipped branch it computes
* over 0 without reading or writing. `at` is where the name starts.
*/
step(target, step, prefix, at) {
if (this.skip > 0)
return prefix ? step : 0n;
const [key, value] = this.lookup(target, at);
const stepped = wrapInt64(value + step);
this.record.writeTarget(target, key, stepped, this.subscript);
return prefix ? stepped : value;
}
/**
* The element key `target` names and the value it holds. A variable read
* from an expression `ARITH_MAX_DEPTH` values deep is past the recursion
* limit, which names this expression and the reference (`x='(x)'` is
* `(x): expression recursion level exceeded (error token is "x)")`).
*/
lookup(target, at) {
if (this.depth >= ARITH_MAX_DEPTH)
throw this.fail('expression recursion level exceeded', at);
const record = this.record;
const key = record.keyOf(target, this.depth);
return [key, record.readTarget(target, key, this.depth, this.subscript)];
}
primary() {
if (this.at('(')) {
this.advance();
const value = this.comma();
if (!this.at(')'))
throw this.stray("missing `)'");
this.advance();
return value;
}
if (this.is('num')) {
const value = this.value;
this.advance();
return value;
}
if (this.is('name')) {
const [target, at] = [this.tok, this.tokStart];
this.advance();
if (this.is('post')) {
const value = this.step(target, this.tok === '++' ? 1n : -1n, false, at);
this.advance();
return value;
}
// A name before `=` here is a target the assignment level refuses
// (`1+x=2`), and bash reads nothing of it.
if (this.skip > 0 || this.at('='))
return 0n;
return this.lookup(target, at)[1];
}
throw this.fail('syntax error: operand expected');
}
}
/** A target's name and its subscript, null for a bare name. */
function splitTarget(target) {
const bracket = target.indexOf('[');
if (bracket === -1)
return [target, null];
return [target.slice(0, bracket), target.slice(bracket + 1, -1)];
}
/**
* One evaluation: what it reads and every write it makes. Reads resolve
* through `updates` first, then `env`; every write lands in `updates` (or
* `elemUpdates` for an element) and in `writes`, the one ordered record
* across both kinds, so the caller lands them in the order the expression
* made them. A variable's value and an indexed subscript are expressions
* of their own, read in this same record (`x='y=5'; $((x))` leaves y at 5)
* one level deeper. A write to a name `frozen` holds stops the evaluation
* there (`ReadonlyVariableError`). Mirrors Python's _Record.
*/
class ArithRecord {
env;
elements;
readVar;
wroteVar;
nounset;
frozen;
updates = new Map();
elemUpdates = new Map();
writes = [];
constructor(env, elements, readVar, wroteVar, nounset, frozen) {
this.env = env;
this.elements = elements;
this.readVar = readVar;
this.wroteVar = wroteVar;
this.nounset = nounset;
this.frozen = frozen;
}
/** The value of `text` read as an expression in this record. */
evaluate(text, depth, subscript) {
let start = 0;
while (start < text.length && ARITH_BLANKS.includes(text[start] ?? ''))
start++;
const expr = text.slice(start);
try {
return new Reader(this, expr, depth, subscript).run();
}
catch (err) {
if (!(err instanceof RangeError))
throw err;
throw new ArithError('expression recursion level exceeded', expr, expr);
}
}
/** A variable's value as a number: its text read as an expression. */
coerce(raw, depth, subscript) {
const text = raw ?? '';
return plainDecimal(text) ?? this.evaluate(text, depth + 1, subscript);
}
mergedEnv() {
return { ...this.env, ...Object.fromEntries(this.updates) };
}
/**
* The canonical element key a target names, null for a scalar. Resolved
* once per reference: a compound assignment or a `++` reads and writes
* the same element, and a subscript that draws (`a[RANDOM]+=1`) draws
* once.
*/
keyOf(target, depth) {
const [name, inner] = splitTarget(target);
if (inner === null)
return null;
const elements = this.elements;
if (elements === null) {
throw new ArithError('syntax error: operand expected', target, target.slice(name.length));
}
if (elements.isAssoc?.(name) ?? true)
return elements.resolve(name, inner, this.mergedEnv());
let index = plainDecimal(inner);
if (index === null) {
try {
index = this.evaluate(inner, depth + 1, true);
}
catch (err) {
if (err instanceof ArithError)
err.inSubscript = true;
throw err;
}
}
return elements.resolve(name, index.toString(), this.mergedEnv());
}
/**
* The value a target holds. A bare name a dynamic reader answers
* (`RANDOM`) is asked first, the pending writes next, then the
* environment; an array's bare name reads element 0.
*/
readTarget(target, key, depth, subscript) {
const [name] = splitTarget(target);
if (key !== null) {
const pending = this.elemUpdates.get(`${name} ${key}`);
const raw = pending ?? (this.elements === null ? null : this.elements.read(name, key));
return this.coerce(raw, depth, subscript);
}
const dynamic = this.readVar?.(name) ?? null;
if (dynamic !== null)
return this.coerce(dynamic, depth, subscript);
const pending = this.updates.get(name) ?? (Object.hasOwn(this.env, name) ? this.env[name] : undefined);
if (pending !== undefined)
return this.coerce(pending, depth, subscript);
const element = this.elements === null ? null : this.elements.read(name, '0');
if (element === null && this.nounset && this.elements?.holdsArray?.(name) !== true) {
throw new UnboundVariable(name);
}
return this.coerce(element, depth, subscript);
}
/** Record a write, or refuse one to a readonly name. */
writeTarget(target, key, value, subscript) {
const [name] = splitTarget(target);
const refused = this.frozen?.(name) ?? null;
if (refused !== null)
throw new ReadonlyVariableError(refused, subscript);
const text = value.toString();
this.writes.push({ name, key, value: text });
if (key !== null) {
this.elemUpdates.set(`${name} ${key}`, text);
return;
}
this.updates.set(name, text);
this.wroteVar?.(name, text);
}
}
/**
* Evaluate a bash arithmetic expression.
*
* bash's grammar over 64-bit wrapping integers (BigInt), read and
* evaluated in one pass as bash does (`Reader`): comma sequences,
* assignment operators, the ternary, short-circuit `&&`/`||`, the bitwise,
* comparison, shift and arithmetic operators, right-grouping `**`, unary
* operators, `++`/`--`, and integer constants in any base from 2 to 64. A
* variable whose value is not a plain number is read as an expression of
* its own (`x="1+2"; $((x))` is 3). An error is worded as bash's line,
* naming the innermost expression it happened in (`x='1+'; $((x+1))`
* names `1+`). Element references (`a[i]`, `m[key]`) resolve and assign
* through `elements`; with null every subscript is a syntax error, which
* is what an evaluation with no session behind it can honestly say.
* `nounset` is `set -u` for the names the expression reads: one that no
* variable holds throws UnboundVariable instead of reading 0. `frozen`
* names the readonly variable a write to a name reaches, through a
* reference, or null: the evaluation stops there with ReadonlyVariableError. An
* ArithError or ReadonlyVariableError carries the writes made before it. `added`
* is a second expression read after `expr` in the same record and added to
* it: an integer `+=`, whose held value and added text bash evaluates in
* turn, the second seeing what the first assigned (`n='x=5'; n+=x` stores
* 10), each error naming its own side.
*/
export function evaluateArith(expr, env, elements = null, readVar = null, wroteVar = null, nounset = false, frozen = null, added = null) {
const record = new ArithRecord(env, elements, readVar, wroteVar, nounset, frozen);
let value;
try {
value = record.evaluate(expr, 0, false);
if (added !== null)
value = wrapInt64(value + record.evaluate(added, 0, false));
}
catch (err) {
if (err instanceof ArithError || err instanceof ReadonlyVariableError)
err.writes = [...record.writes];
throw err;
}
return { value, writes: record.writes };
}
//# sourceMappingURL=arith.js.map