@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
333 lines • 12.4 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 { z } from 'zod';
import { YieldBudget } from "../io/yield_budget.js";
import { fromByteView } from "../shell/bytes.js";
import { concat } from "../utils/bytes.js";
import { RegexError, SearchLimit, StreamRefusal } from "./errors.js";
import { MovableStream } from "./stream.js";
const UNSET = 0xffffffff;
const SPECIAL = /[\\.[\]()*+?{}|^$]/g;
const decoder = new TextDecoder();
const posixMeta = z.object({ groups: z.number().int().nonnegative() });
const lineMeta = z.object({
warnings: z.array(z.string()),
needles: z.array(z.string()).nullable(),
});
/** `text` as a POSIX extended expression that matches only itself. Mirrors Python's posix_escape. */
export function posixEscape(text) {
return text.replace(SPECIAL, '\\$&');
}
function latin1(bytes) {
let out = '';
for (let at = 0; at < bytes.length; at += 4096)
out += String.fromCharCode(...bytes.subarray(at, at + 4096));
return out;
}
function byteLength(code) {
if (code < 0x80)
return 1;
if (code < 0x800)
return 2;
// A byte that is no part of a character rides as its surrogate escape.
if (code >= 0xdc80 && code <= 0xdcff)
return 1;
return 3;
}
/**
* A byte view as the bytes mirage-rs searches, and the way back from a byte
* offset to an index of the view. Mirrors Python's `_Subject`.
*/
class Subject {
data;
starts;
constructor(text, utf8) {
this.data = fromByteView(text, utf8);
if (this.data.length === text.length) {
this.starts = null;
return;
}
const starts = new Int32Array(text.length + 1);
let offset = 0;
for (let i = 0; i < text.length; i++) {
starts[i] = offset;
const code = text.charCodeAt(i);
const next = text.charCodeAt(i + 1);
if (code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
starts[i + 1] = offset;
offset += 4;
i++;
continue;
}
offset += byteLength(code);
}
starts[text.length] = offset;
this.starts = starts;
}
offset(index) {
return this.starts === null ? index : (this.starts[index] ?? index);
}
index(offset) {
const starts = this.starts;
if (starts === null)
return offset;
let low = 0;
let high = starts.length - 1;
while (low < high) {
const mid = (low + high) >> 1;
if ((starts[mid] ?? 0) < offset)
low = mid + 1;
else
high = mid;
}
return low;
}
}
function words(data) {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const out = [];
for (let at = 0; at + 4 <= data.byteLength; at += 4)
out.push(view.getUint32(at, true));
return out;
}
function spans(subject, data) {
const found = words(data);
const out = [];
for (let at = 0; at + 1 < found.length; at += 2)
out.push([subject.index(found[at] ?? 0), subject.index(found[at + 1] ?? 0)]);
return out;
}
function open(config) {
try {
return new MovableStream('regex', config);
}
catch (err) {
if (err instanceof StreamRefusal)
throw new RegexError(err.message);
throw err;
}
}
/**
* The regex streams one compiled expression searches with. A search out of
* steps stays in its stream, busy, until the caller's drains finish it, so
* each search takes a stream of its own: an idle one, or a new one while every
* stream is in another task's search. A stream an aborted search left busy
* still holds its subject, so it closes rather than going back. Mirrors
* Python's `_StreamPool`.
*/
class StreamPool {
config;
metadata;
idle;
constructor(config) {
this.config = config;
const first = open(config);
this.idle = [first];
this.metadata = JSON.parse(decoder.decode(first.live.metadata()));
}
/**
* The answer to `request` about `data`, yielding to the loop between the
* drains of a long search. A refusal throws StreamRefusal with what the
* search found.
*/
async ask(request, data, signal) {
const movable = this.idle.pop() ?? open(this.config);
let stream = movable.live;
try {
const budget = new YieldBudget(signal);
for (;;) {
stream = movable.live;
stream.begin(request);
const parts = [stream.feed(data), stream.finish()];
for (;;) {
if (!stream.busy())
return concat(parts);
await budget.run();
// A stream closes with its instance when another stream on it
// traps; the search starts over on the next one.
if (!stream.live)
break;
parts.push(stream.drain());
}
}
}
finally {
if (stream.live && stream.busy())
movable.close();
else
this.idle.push(movable);
}
}
}
/**
* One POSIX expression, searched against whole subjects. A match is the
* leftmost one and the longest there; its groups lie where the first way of
* matching that text puts them, trying a repetition's longer counts and an
* alternation's earlier branches first. Offsets count elements of the byte
* view the subject arrives as. `dialect` `sed` reads the expression as GNU
* sed does, `posix` as regcomp does for expr, nl, bash and git; `multiline`
* is sed's `M`, and `word` git grep's -w. A long search yields to the loop
* between its bounded calls. A refused expression throws
* RegexError in regerror's wording. Mirrors Python's PosixRegex.
*/
export class PosixRegex {
utf8;
groups;
streams;
constructor(pattern, extended = false, dialect = 'posix', ignoreCase = false, utf8 = false, multiline = false, word = false) {
this.utf8 = utf8;
const config = {
mode: 'posix',
pattern: latin1(fromByteView(pattern, utf8)),
extended,
dialect,
ignore_case: ignoreCase,
utf8,
multiline,
word,
};
this.streams = new StreamPool(config);
this.groups = posixMeta.parse(this.streams.metadata).groups;
}
spans(subject, found) {
const out = [];
for (let at = 0; at + 1 < found.length; at += 2) {
const start = found[at] ?? UNSET;
const end = found[at + 1] ?? UNSET;
out.push(start === UNSET ? null : [subject.index(start), subject.index(end)]);
}
return out;
}
/** The leftmost-longest match at or after `at`, and its groups. */
async search(text, at = 0, signal) {
const subject = new Subject(text, this.utf8);
const request = { op: 'search', at: subject.offset(at) };
const found = words(await this.streams.ask(request, subject.data, signal));
return found.length === 0 ? null : this.spans(subject, found);
}
/** Every match a global substitution replaces, as sed walks them. */
async all(text, signal) {
const subject = new Subject(text, this.utf8);
const found = words(await this.streams.ask({ op: 'all' }, subject.data, signal));
const width = 2 * (this.groups + 1);
const out = [];
for (let at = 0; at < found.length; at += width)
out.push(this.spans(subject, found.slice(at, at + width)));
return out;
}
/**
* How many times git's pickaxe counts the expression in `text`: each match
* stays in its line, each search resumes where the last one ended (a
* character further after an empty one), and `^` never holds where it
* resumes.
*/
async contains(text, signal) {
const data = fromByteView(text, this.utf8);
return words(await this.streams.ask({ op: 'contains' }, data, signal))[0] ?? 0;
}
/**
* Whether the expression matches anywhere in `text`, as sed's addresses
* ask: with no groups to place, a `$` reads a newline the match goes on to
* consume even in a pattern with groups.
*/
async matches(text, signal) {
const data = fromByteView(text, this.utf8);
return words(await this.streams.ask({ op: 'test' }, data, signal))[0] === 1;
}
}
const CACHE_SIZE = 256;
const cache = new Map();
/** A compiled PosixRegex, shared by every caller asking the same. Mirrors Python's posix_regex. */
export function posixRegex(pattern, extended = false, dialect = 'posix', ignoreCase = false, utf8 = false, multiline = false, word = false) {
const key = JSON.stringify([pattern, extended, dialect, ignoreCase, utf8, multiline, word]);
const hit = cache.get(key);
if (hit !== undefined) {
cache.delete(key);
cache.set(key, hit);
return hit;
}
const regex = new PosixRegex(pattern, extended, dialect, ignoreCase, utf8, multiline, word);
cache.set(key, regex);
if (cache.size > CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest !== undefined)
cache.delete(oldest);
}
return regex;
}
/**
* grep's pattern list, matched against one line at a time: a line matches
* when any pattern does, and `extents` reads matches as -o prints them, the
* leftmost and longest across the list. `line` (-x) outranks `word` (-w). A
* refused pattern throws RegexError in GNU grep's wording. Mirrors Python's
* LineRegex.
*/
export class LineRegex {
ignoreCase;
utf8;
warnings;
needles;
/** Letters fold by Unicode, so `ſ` matches `s` and the Kelvin sign `k`. */
unicodeFolds;
streams;
constructor(patterns, syntax, ignoreCase = false, utf8 = false, word = false, line = false) {
this.ignoreCase = ignoreCase;
this.utf8 = utf8;
const config = {
mode: 'lines',
patterns: patterns.map((part) => latin1(fromByteView(part, utf8))),
syntax,
ignore_case: ignoreCase,
utf8,
word,
line,
};
this.streams = new StreamPool(config);
this.unicodeFolds = ignoreCase && utf8;
const meta = lineMeta.parse(this.streams.metadata);
this.warnings = meta.warnings;
this.needles = meta.needles;
}
/**
* Whether the line, without its terminator, matches. Throws SearchLimit when
* a -P search gives up at PCRE2's match limit.
*/
async matches(line, signal) {
try {
const found = await this.streams.ask({ op: 'test' }, fromByteView(line, this.utf8), signal);
return found[0] === 1;
}
catch (err) {
if (err instanceof StreamRefusal)
throw new SearchLimit(err.message, []);
throw err;
}
}
/**
* Each match grep -o prints from the line. Throws SearchLimit, carrying the
* matches found before it, when a -P search gives up at PCRE2's match limit.
*/
async extents(line, signal) {
const subject = new Subject(line, this.utf8);
try {
return spans(subject, await this.streams.ask({ op: 'extents' }, subject.data, signal));
}
catch (err) {
if (err instanceof StreamRefusal)
throw new SearchLimit(err.message, spans(subject, err.output));
throw err;
}
}
}
//# sourceMappingURL=regex.js.map