@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
475 lines • 20.5 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 { IOResult } from "../../../io/types.js";
import { command } from "../../config.js";
import { specOf } from "../../spec/builtins.js";
import { byteView, fromByteView, utf8Locale } from "../../../shell/bytes.js";
import { UsageError } from "../../errors.js";
import { RegexError } from "../../../rs/errors.js";
import { posixRegex } from "../../../rs/regex.js";
import { quoteWord } from "../../quote.js";
const CMP_OPS = new Set(['=', '==', '!=', '<', '<=', '>', '>=']);
const ADD_OPS = new Set(['+', '-']);
const MUL_OPS = new Set(['*', '/', '%']);
// GNU expr's operand grammar, which is narrower than either language's
// own integer parser: no sign but a leading `-`, no surrounding space,
// no digit separator, no `0x`/`1e3` form. Leading zeros are decimal, so
// `05` is 5. `\d` is ASCII-only in JavaScript, matching `[0-9]` in the
// python twin (`INT_OPERAND_RE`, expr.py), whose twin is read with
// `fullmatch` because python's `$` also matches before a trailing
// newline; JavaScript's `$` is already end-of-input.
//
// The grammar is read into a `bigint`, not a `number`, because GNU expr
// is arbitrary precision and this is the only way to be. A float64 could
// not merely round: it turned a comparison's answer upside down, because
// an operand past ~1.8e308 read as `Infinity`, `Number.isFinite` reported
// it as not an integer at all, and the comparison silently fell back to
// comparing strings -- `expr 2 '<' 1e320` answered 0 where GNU answers 1.
// It also printed what expr cannot print and cannot read back, since
// `String(1e24)` is `1e+24`.
//
// This host has no digit limit; the python twin does, and it is the one
// place the two still part company. CPython caps base-10 `int(str)` and
// `str(int)` at `sys.get_int_max_str_digits()`, 4300 by default, so an
// operand or a product longer than that raises there while this answers.
const INT_OPERAND_RE = /^-?\d+$/;
const NON_INTEGER = 'expr: non-integer argument';
const DIVISION_BY_ZERO = 'expr: division by zero';
// GNU's only two-line diagnostic, and the only one it reaches for when
// the line leaves zero expression words -- `expr` and `expr --`, since
// `--` is consumed as the options terminator. Every other refusal is one
// `syntax error: <detail>` line.
const MISSING_OPERAND = "expr: missing operand\nTry 'expr --help' for more information.\n";
// GNU declares no nesting limit and segfaults on a C-stack overflow at
// somewhere past 10000 parentheses, with nothing on stderr, so there is
// no message to copy. This limit is ours: it keeps the recursive descent
// well inside CPython's default recursion limit on the python side (eight
// frames per level) and is far past any expression written by hand.
const MAX_NESTING = 64;
const NESTING_TOO_DEEP = `expr: expression nesting too deep (limit ${String(MAX_NESTING)})`;
// GNU's wording for an operator with nothing to its right. The word is
// the last one the parser consumed, which is what GNU names here rather
// than the operator that needed an operand -- `expr substr abc 1` reports
// `1`, not `substr`. It outranks an unclosed parenthesis: `expr '(' 1 +`
// reports this, not `expecting ')'`.
// `utf8` means the word is text, under a UTF-8 locale.
function missingArgumentAfter(word, utf8 = false) {
return `expr: syntax error: missing argument after '${quoteWord(word, utf8)}'`;
}
// GNU's wording for a leftover word the grammar had no slot for.
function unexpectedArgument(word, utf8 = false) {
return `expr: syntax error: unexpected argument '${quoteWord(word, utf8)}'`;
}
// GNU's wording for a parenthesis that was never closed.
//
// GNU has two clauses here and picks between them on one fact: whether
// the line ran out or a word is standing where the `)` belonged. It names
// the last word consumed in the first case and the offending word in the
// second, so `expr '(' 1` reports `after '1'` while `expr '(' 1 1`
// reports `instead of '1'` -- the same text for different reasons, and
// different text for what looks like the same error.
function expectingClose(current, prev, utf8 = false) {
if (current === null) {
return `expr: syntax error: expecting ')' after '${quoteWord(prev, utf8)}'`;
}
return `expr: syntax error: expecting ')' instead of '${quoteWord(current, utf8)}'`;
}
// The one detail clause with no `argument` noun in it, for a `)` where a
// primary was expected. A `)` left over at the *end* of a complete
// expression is reported by `unexpectedArgument` instead.
const UNEXPECTED_CLOSE = "expr: syntax error: unexpected ')'";
// One expr operand read as GNU reads it, or null when it is not an
// integer in GNU's grammar -- the read a comparison uses, since a
// comparison falls back to comparing strings instead of refusing. A
// value out of every float's range is still an integer here, which is
// the whole point of the `bigint`: `null` means "compare as strings".
function intOperandOrNone(s) {
if (!INT_OPERAND_RE.test(s))
return null;
return BigInt(s);
}
// The same read for an arithmetic operand, which GNU refuses outright.
function parseIntOperand(s) {
const n = intOperandOrNone(s);
if (n === null)
throw new UsageError(NON_INTEGER);
return n;
}
// Whether GNU expr counts a value as false. False is not "empty or the
// character zero": the empty string is false, and so is any run of
// zeros with at most one leading minus, which is why `expr 00 '&' 1` is
// false and `expr - '&' 1` is not.
export function isNull(value) {
if (value === '')
return true;
const digits = value.startsWith('-') ? value.slice(1) : value;
if (digits === '')
return false;
for (const ch of digits) {
if (ch !== '0')
return false;
}
return true;
}
// Integer division truncated toward zero, as C and GNU expr do it.
// `bigint` division already truncates, so there is nothing to round; a
// zero divisor is GNU's `division by zero`, which is also why this cannot
// be left to the operator (`bigint` raises `RangeError` instead).
function truncDiv(a, b) {
if (b === 0n)
throw new UsageError(DIVISION_BY_ZERO);
return a / b;
}
// The remainder that takes the dividend's sign, as GNU expr does:
// `-10 % 3` is -1 and `10 % -3` is 1, which JavaScript's own `%`
// already answers. GNU reports a zero divisor here with the same
// `division by zero` message it uses for `/`, not a modulo variant.
function truncMod(a, b) {
if (b === 0n)
throw new UsageError(DIVISION_BY_ZERO);
return a % b;
}
// Compare two operands the way GNU's `=` family compares them. The
// comparison is numeric only when *both* sides are integers in GNU's
// grammar; one bad side makes it a byte-order string compare, so
// `expr 10 '>' 9a` is false and `expr '+1' '=' 1` is false. That fallback
// is GNU's `strcmp`, and it is the byte order because both sides are byte
// views by the time they arrive.
function orderOf(left, right) {
const a = intOperandOrNone(left);
const b = intOperandOrNone(right);
if (a !== null && b !== null)
return a > b ? 1 : a < b ? -1 : 0;
return left > right ? 1 : left < right ? -1 : 0;
}
// One comparison, answered as GNU's `1` or `0`. `==` is an undocumented
// synonym for `=`.
function compare(left, op, right) {
const order = orderOf(left, right);
let held;
if (op === '=' || op === '==')
held = order === 0;
else if (op === '!=')
held = order !== 0;
else if (op === '<')
held = order < 0;
else if (op === '<=')
held = order <= 0;
else if (op === '>')
held = order > 0;
else
held = order >= 0;
return held ? '1' : '0';
}
// Compile a BRE, re-wording glibc's refusal under expr's own program
// prefix, as GNU prints it.
function compileOrRefuse(pattern, utf8) {
try {
return posixRegex(pattern, false, 'posix', false, utf8);
}
catch (err) {
if (err instanceof RegexError)
throw new UsageError(`expr: ${err.message}`, 2, { cause: err });
throw err;
}
}
// The `:` operator, which `match` is the prefix spelling of. POSIX: the
// pattern is a BRE anchored at the start of the subject. A pattern with a
// group answers with what group 1 matched -- the empty string when the match
// failed or the group took no part -- and one without answers with the length
// of the match, `0` when there is none. Whether it has a group is the
// pattern's business alone, so a failed `expr abc : '\(x\)'` is empty, not
// `0`.
//
// Both strings are byte views, so `.` matches one byte and the length counts
// bytes: GNU reads `expr <e-acute> : '.'` as 1 and `expr <e-acute> : '\(.\)'`
// as the single byte `c3`. Under a UTF-8 locale (`utf8`) both are text and
// the same two answer the whole character, counted by code point. Mirrors
// Python's colon_match.
async function colonMatch(subject, pattern, utf8 = false, signal) {
const regex = compileOrRefuse(pattern, utf8);
let found = await regex.search(subject, 0, signal);
// The leftmost match is the one at the start, when there is one.
if (found !== null && found[0]?.[0] !== 0)
found = null;
if (regex.groups > 0) {
const group = found?.[1] ?? null;
return group === null ? '' : subject.slice(group[0], group[1]);
}
const whole = found?.[0] ?? null;
return whole === null ? '0' : String(Array.from(subject.slice(0, whole[1])).length);
}
// The `index` operator, which is `strcspn` over a character set. It is
// not a substring search: `expr index abcde ec` is 3, because `c` sits
// earlier in the subject than `e` does. Both arguments are byte views, so
// the set is a set of bytes and the position counts bytes:
// `expr index <a-umlaut> <e-acute>` is 1, because both characters begin
// with the byte `c3`. Every count here goes by code point, which is one
// byte in a byte view and one character in a UTF-8 locale's text.
function doIndex(text, charSet) {
const wanted = new Set(charSet);
let offset = 0;
for (const ch of text) {
offset += 1;
if (wanted.has(ch))
return String(offset);
}
return '0';
}
// The `substr` operator, 1-based and forgiving. A position that is zero,
// negative, past the end or not a number at all is not an error: GNU
// answers with the empty string, which makes expr exit 1. A negative or
// zero length answers the same way, and an over-long one clamps.
//
// The subject is a byte view, so both the position and the length count
// bytes and a slice may land mid-character. GNU does exactly that and
// prints the half it selected, so this does too. The length is clamped to
// the subject before it leaves `bigint`, because `expr substr abcde 1
// 1e320` is a legal line and `Number` of that offset is not an index.
function doSubstr(text, posArg, lenArg) {
const start = intOperandOrNone(posArg);
const count = intOperandOrNone(lenArg);
if (start === null || count === null)
return '';
const chars = Array.from(text);
const size = BigInt(chars.length);
if (start < 1n || count < 0n || start > size)
return '';
const from = Number(start - 1n);
return chars.slice(from, from + Number(count > size ? size : count)).join('');
}
// How many operands each prefix keyword reads: GNU's `match`, `substr`,
// `index` and `length`.
const KEYWORD_ARITY = new Map([
['match', 2],
['substr', 3],
['index', 2],
['length', 1],
]);
// An expr line read and evaluated in one left-to-right pass.
//
// The grammar is POSIX's, loosest first: `|`, `&`, the six comparisons (and
// GNU's `==`), `+ -`, `* / %`, then `:`, every level left-associative. Under
// `:` sit GNU's operands: a prefix keyword (`match`, `substr`, `index`,
// `length`) with its operands, `+ WORD` quoting one word, `( EXPR )`, or any
// other single word, operator words included (`expr '*'` prints `*`). There
// is no unary operator: `-5` is a word and so is `-`.
//
// `live` is false inside an operand `|` or `&` has already decided (`expr 1
// '|' a / 0` prints 1): the operand is still read, so a syntax error in it
// still counts, but nothing in it is computed and so nothing in it can refuse
// a value -- not a non-integer, not a zero divisor, not a bad pattern.
//
// Every word and value is a byte view (see exprEval), or text under a UTF-8
// locale (`utf8`). Mirrors Python's ExprReader.
class ExprReader {
words;
utf8;
signal;
next = 0;
depth = 0;
constructor(words, utf8 = false, signal) {
this.words = words;
this.utf8 = utf8;
this.signal = signal;
}
done() {
return this.next >= this.words.length;
}
ahead() {
return this.words[this.next] ?? null;
}
lastRead() {
return this.words[this.next - 1] ?? '';
}
// The next word, which has to be there: when the line ran out, GNU names
// the word before.
readWord() {
const word = this.words[this.next];
if (word === undefined)
throw new UsageError(missingArgumentAfter(this.lastRead(), this.utf8));
this.next++;
return word;
}
takeIf(wanted) {
const word = this.ahead();
if (word === null)
return null;
if (typeof wanted === 'string' ? word !== wanted : !wanted.has(word))
return null;
this.next++;
return word;
}
// `A | B`: A unless it is null or 0, else B unless it is, else 0.
async either(live) {
let left = await this.both(live);
while (this.takeIf('|') !== null) {
const right = await this.both(live && isNull(left));
if (live && isNull(left))
left = isNull(right) ? '0' : right;
}
return left;
}
// `A & B`: A when neither is null or 0, else 0.
async both(live) {
let left = await this.relation(live);
while (this.takeIf('&') !== null) {
const right = await this.relation(live && !isNull(left));
if (live)
left = isNull(left) || isNull(right) ? '0' : left;
}
return left;
}
async relation(live) {
let left = await this.sum(live);
for (let op = this.takeIf(CMP_OPS); op !== null; op = this.takeIf(CMP_OPS)) {
const right = await this.sum(live);
if (live)
left = compare(left, op, right);
}
return left;
}
async sum(live) {
let left = await this.product(live);
for (let op = this.takeIf(ADD_OPS); op !== null; op = this.takeIf(ADD_OPS)) {
const right = await this.product(live);
if (live) {
const a = parseIntOperand(left);
const b = parseIntOperand(right);
left = String(op === '+' ? a + b : a - b);
}
}
return left;
}
async product(live) {
let left = await this.matching(live);
for (let op = this.takeIf(MUL_OPS); op !== null; op = this.takeIf(MUL_OPS)) {
const right = await this.matching(live);
if (live) {
const a = parseIntOperand(left);
const b = parseIntOperand(right);
if (op === '*')
left = String(a * b);
else if (op === '/')
left = String(truncDiv(a, b));
else
left = String(truncMod(a, b));
}
}
return left;
}
async matching(live) {
let left = await this.operand(live);
while (this.takeIf(':') !== null) {
const right = await this.operand(live);
if (live)
left = await colonMatch(left, right, this.utf8, this.signal);
}
return left;
}
// One operand under `:`, prefix keywords applied. A keyword reads its own
// operands at this same level, so `length length abc` is 1 and `substr + abc
// 1 1` is `a`. Those chains are kept on a list here rather than on the call
// stack: GNU reads one of any length, and only parentheses count toward the
// nesting limit.
async operand(live) {
const waiting = [];
for (;;) {
const word = this.readWord();
if (KEYWORD_ARITY.has(word)) {
waiting.push([word, []]);
continue;
}
let value = await this.simple(word, live);
for (let top = waiting.at(-1); top !== undefined; top = waiting.at(-1)) {
const [keyword, given] = top;
given.push(value);
if (given.length < (KEYWORD_ARITY.get(keyword) ?? 0))
break;
waiting.pop();
value = live ? await this.keyword(keyword, given) : '';
}
if (waiting.length === 0)
return value;
}
}
async keyword(keyword, given) {
const [first = '', second = '', third = ''] = given;
if (keyword === 'length')
return String(Array.from(first).length);
if (keyword === 'index')
return doIndex(first, second);
if (keyword === 'substr')
return doSubstr(first, second, third);
return colonMatch(first, second, this.utf8, this.signal);
}
// `+ WORD`, `( EXPR )` or a word standing for itself, the word already read.
async simple(word, live) {
if (word === '+')
return this.readWord();
if (word === ')')
throw new UsageError(UNEXPECTED_CLOSE);
if (word !== '(')
return word;
if (this.depth >= MAX_NESTING)
throw new UsageError(NESTING_TOO_DEEP);
this.depth++;
const value = await this.either(live);
this.depth--;
if (this.takeIf(')') === null) {
throw new UsageError(expectingClose(this.ahead(), this.lastRead(), this.utf8));
}
return value;
}
}
// Evaluate a whole expr line, answering the value to print and the exit
// code. GNU exits 1 when the value is false even on full success, so exit
// 1 means "the answer was zero or empty" and exit 2 is the only error
// status. The words in and the value out are byte views, or text under a
// UTF-8 locale (`utf8`).
export async function exprEval(args, utf8 = false, signal) {
const reader = new ExprReader(args, utf8, signal);
const value = await reader.either(true);
const leftover = reader.ahead();
if (leftover !== null)
throw new UsageError(unexpectedArgument(leftover, utf8));
return [value, isNull(value) ? 1 : 0];
}
async function expr(_accessor, paths, texts, ctx) {
if (texts.length === 0) {
return [null, new IOResult({ exitCode: 2, stderr: fromByteView(MISSING_OPERAND) })];
}
const utf8 = utf8Locale(ctx.env);
try {
const [result, exitCode] = await exprEval(texts.map((text) => byteView(text, utf8)), utf8, ctx.signal);
return [fromByteView(result + '\n', utf8), new IOResult({ exitCode })];
}
catch (err) {
if (err instanceof UsageError) {
// GNU writes the refusal to stderr, nothing to stdout, and exits
// 2; exit 1 is reserved for a zero-valued success. The diagnostic
// quotes a byte view of the offending word, so it leaves through
// the same entry point the value does.
return [null, new IOResult({ exitCode: 2, stderr: fromByteView(`${err.message}\n`, utf8) })];
}
throw err;
}
}
export const GENERAL_EXPR = command({
name: 'expr',
vfs: null,
spec: specOf('expr'),
fn: expr,
});
//# sourceMappingURL=expr.js.map