UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

333 lines • 12.4 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 { 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