@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
276 lines • 10.1 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 { decodeText } from "../../shell/bytes.js";
const REPETITION = /\{[0-9]*(,[0-9]*)?\}/y;
const ALNUM = /^[\p{L}\p{N}]$/u;
/**
* Whether the output shows -A/-B/-C context, which also puts the context
* separator between one file's lines and the next file's. Only printed
* lines carry it: -c, -l and --files-without-match answer per file. -o
* keeps it, each line printed as its matches. --passthru prints every line
* but never separates groups.
*/
export function printsContext(f) {
if (f.countOnly ||
f.countMatches ||
f.filesOnly ||
f.filesWithoutMatch ||
f.quiet ||
f.passthru) {
return false;
}
return f.contextBefore > 0 || f.contextAfter > 0;
}
function charAt(text, i) {
const cp = text.codePointAt(i);
return cp === undefined ? '' : String.fromCodePoint(cp);
}
// The literal a backslash escape at `i` stands for, if any, and where the
// pattern resumes.
function escapeLiteral(pattern, i) {
if (i + 1 >= pattern.length)
return [null, pattern.length];
const nxt = charAt(pattern, i + 1);
if (nxt === 'p' || nxt === 'P') {
if (pattern[i + 2] === '{') {
const close = pattern.indexOf('}', i + 3);
return [null, close === -1 ? pattern.length : close + 1];
}
return [null, i + 3];
}
if (nxt === 'x') {
let digits;
let end;
if (pattern[i + 2] === '{') {
const close = pattern.indexOf('}', i + 3);
digits = close === -1 ? '' : pattern.slice(i + 3, close);
end = close === -1 ? pattern.length : close + 1;
}
else {
digits = pattern.slice(i + 2, i + 4);
end = i + 4;
}
if (!/^[0-9A-Fa-f]+$/.test(digits))
return [null, end];
const cp = Number.parseInt(digits, 16);
return [cp <= 0x10ffff ? String.fromCodePoint(cp) : null, end];
}
if (ALNUM.test(nxt))
return [null, i + 1 + nxt.length];
return [nxt, i + 1 + nxt.length];
}
/**
* The literal characters of one ripgrep pattern, which is all smart case
* looks at: class members and escaped punctuation count, while escapes like
* `\w`, repetition counts, group syntax and POSIX class names do not.
*/
function* regexLiterals(pattern) {
let i = 0;
let inClass = false;
while (i < pattern.length) {
const ch = charAt(pattern, i);
if (ch === '\\') {
const [literal, next] = escapeLiteral(pattern, i);
if (literal !== null)
yield literal;
i = next;
continue;
}
if (inClass) {
if (ch === ']') {
inClass = false;
i += 1;
}
else if (pattern.startsWith('[:', i)) {
const close = pattern.indexOf(':]', i + 2);
i = close === -1 ? i + 1 : close + 2;
}
else {
if (ch !== '-')
yield ch;
i += ch.length;
}
continue;
}
if (ch === '[') {
inClass = true;
i += 1;
if (pattern[i] === '^')
i += 1;
if (pattern[i] === ']') {
yield ']';
i += 1;
}
continue;
}
if (pattern.startsWith('(?', i)) {
let j = i + 2;
if (pattern.startsWith('P<', j) ||
(pattern.startsWith('<', j) && !pattern.startsWith('<=', j) && !pattern.startsWith('<!', j))) {
const close = pattern.indexOf('>', j);
i = close === -1 ? pattern.length : close + 1;
continue;
}
while (j < pattern.length && pattern[j] !== ':' && pattern[j] !== ')')
j += 1;
i = j < pattern.length && pattern[j] === ':' ? j + 1 : j;
continue;
}
if (ch === '{') {
REPETITION.lastIndex = i;
const rep = REPETITION.exec(pattern);
if (rep !== null) {
i += rep[0].length;
continue;
}
}
if (!'.^$*+?()|{'.includes(ch))
yield ch;
i += ch.length;
}
}
// Unicode's Uppercase property, which ripgrep's -S asks of a literal: a
// letter like `ℂ` has no lowercase and still counts.
function isUpper(ch) {
return /^\p{Uppercase}$/u.test(ch);
}
/**
* Whether -S searches `pattern` without regard to case: it has at least one
* literal character and none of them is uppercase. `fixedString` is -F,
* where every character is a literal.
*/
export function smartCaseFolds(pattern, fixedString) {
let found = false;
for (const part of pattern.split('\n')) {
for (const ch of fixedString ? Array.from(part) : regexLiterals(part)) {
found = true;
if (isUpper(ch))
return false;
}
}
return found;
}
/**
* What one haystack's search selected, beside what it printed: `binary` when
* a binary-file notice stood in for the lines, `skipped` when a walked
* binary file went unsearched, which no listing names but
* --files-without-match's status counts.
*/
// What one haystack's search selected, beside what it printed. The counts
// are kept for --stats and --json: the matches in the selected lines, the
// selected lines, the bytes read before the search stopped, and a binary
// file's first NUL.
// Whether the search ignores case: -i, or -S over a pattern with no
// uppercase literal in it.
export function foldsCase(pattern, fixed, f) {
return f.ignoreCase || (f.smartCase && smartCaseFolds(pattern, fixed));
}
/** What --stats and the --json summary add up over the haystacks. */
export class Totals {
matches = 0;
matchedLines = 0;
withMatch = 0;
searched = 0;
printed = 0;
bytesSearched = 0;
elapsed = 0;
/** Add another run's totals: a line split over mounts prints one footer for all its runs. */
merge(other) {
this.matches += other.matches;
this.matchedLines += other.matchedLines;
this.withMatch += other.withMatch;
this.searched += other.searched;
this.printed += other.printed;
this.bytesSearched += other.bytesSearched;
this.elapsed += other.elapsed;
}
/** Count one haystack: its tally, the bytes printed for it, its seconds. */
add(tally, printed, elapsed) {
this.matches += tally.matches ?? 0;
this.matchedLines += tally.matchedLines ?? 0;
this.withMatch += tally.selected ? 1 : 0;
this.searched += 1;
this.printed += printed;
this.bytesSearched += tally.searched ?? 0;
this.elapsed += elapsed;
}
}
const STATS_ROWS = [
'matches',
'matched lines',
'files contained matches',
'files searched',
'bytes printed',
'bytes searched',
'seconds spent searching',
'seconds',
];
/**
* One run's output with the footer it ends with cut off, and the totals that
* footer printed, read back the way the wc and du combiners read their total
* rows; the output whole and null when it ends in none. `summary` reads
* --json's summary record, not the --stats block. Mirrors Python's
* `split_footer`.
*/
export function splitFooter(data, summary) {
if (data[data.length - 1] !== 10)
return [data, null];
// Back from the final newline to the one before the footer's first line.
let at = data.length - 1;
for (let k = 0; k < (summary ? 1 : STATS_ROWS.length); k++) {
at = at > 0 ? data.lastIndexOf(10, at - 1) : -1;
if (at < 0 && (!summary || k > 0))
return [data, null];
}
const rows = decodeText(data.subarray(at + 1, data.length - 1)).split('\n');
const totals = new Totals();
if (summary) {
const last = rows[0] ?? '';
if (!last.startsWith('{"data":') || !last.endsWith('"type":"summary"}'))
return [data, null];
const { stats } = JSON.parse(last).data;
totals.matches = stats.matches;
totals.matchedLines = stats.matched_lines;
totals.withMatch = stats.searches_with_match;
totals.searched = stats.searches;
totals.printed = stats.bytes_printed;
totals.bytesSearched = stats.bytes_searched;
totals.elapsed = stats.elapsed.secs + stats.elapsed.nanos / 1e9;
return [data.subarray(0, at + 1), totals];
}
// The block opens with a blank line.
if (at > 0 && data[at - 1] !== 10)
return [data, null];
const values = [];
for (const [k, label] of STATS_ROWS.entries()) {
const row = rows[k] ?? '';
const space = row.indexOf(' ');
if (row.slice(space + 1) !== label || space < 0)
return [data, null];
values.push(row.slice(0, space));
}
totals.matches = Number(values[0]);
totals.matchedLines = Number(values[1]);
totals.withMatch = Number(values[2]);
totals.searched = Number(values[3]);
totals.printed = Number(values[4]);
totals.bytesSearched = Number(values[5]);
totals.elapsed = Number(values[6]);
return [data.subarray(0, at), totals];
}
/** What ends a line rg writes itself: NUL under --null-data, CR LF under --crlf. */
export function recordEnd(f) {
return f.nullData ? '\0' : f.crlf === true ? '\r\n' : '\n';
}
//# sourceMappingURL=rg_search.js.map