@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
428 lines • 17.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 { utf8Locale } from "../../../shell/bytes.js";
import { isStdin, operandLabel } from "../utils/stream.js";
import { stdinStream, stdinStat } from "../utils/stream.js";
import { guardInput } from "../utils/limit.js";
import { specOf } from "../../spec/builtins.js";
import { FlagView } from "../../spec/flag_view.js";
import { fsStrerror, isFsError, isWalkError, walkRefusal } from "../../../errors/fs.js";
import { posixPhrase } from "../../../errors/posix.js";
import { followedStat } from "../../../view/namespace_view.js";
import { respellOne } from "../../../utils/path.js";
import { stripSlash } from "../../../utils/slash.js";
import { mountParentReaddir, mountParentStat, toPathspec } from "../utils/wrap.js";
import { IOResult, materialize } from "../../../io/types.js";
import { concat } from "../../../utils/bytes.js";
import { FileType, LineFacts, PathSpec, } from "../../../types.js";
import { matcherSyntax, patternWarnings, resolvePattern } from "../grep_pattern.js";
import { exitCodeFor } from "../grep_scan.js";
import { BINARY_EXTENSIONS, getExtension } from "../../../utils/filetype.js";
import { nameEnd } from "../grep_binary.js";
import { openGrep, streamConfig, streamInput } from "../grep_stream.js";
import { OutputStream } from "../../../io/stream.js";
import { fileAdmitted, dirAdmitted, parseFileGlobs } from "../grep_select.js";
import { resolveSource } from "../utils/stream.js";
import { UsageError } from "../../errors.js";
import { WalkLimitError } from "../../../errors/types.js";
import { SYNOPSES } from "../../spec/synopsis.js";
import { usageHint } from "../../spec/usage.js";
import { scanned } from "../../../rs/scan.js";
import { GREP_STDIN_LABEL } from "../utils/constants.js";
import { linksOf, mountsOf, mountRoot } from "../utils/links.js";
const ENC = new TextEncoder();
// GNU grep with no pattern prints its synopsis and the help hint, exit 2
// (grep 3.11; the same two lines follow `option requires an argument`).
const GREP_NO_PATTERN = `Usage: ${SYNOPSES.grep ?? ''}\n${usageHint('grep')}`;
// The types -D skip passes over; stdin is read whatever it is.
const DEVICE_TYPES = new Set([
FileType.CHAR_DEVICE,
FileType.BLOCK_DEVICE,
FileType.FIFO,
FileType.SOCKET,
]);
const UNIX_BYTE_OFFSETS = 'grep: warning: --unix-byte-offsets (-u) is obsolete\n';
// The options whose values mirage-rs reads, with the switches that decide
// among them.
const SCANNED = [
'text',
'args_I',
'binary_files',
'after_context',
'before_context',
'context',
'recursive',
'dereference_recursive',
'directories',
'devices',
];
/**
* grep's option values but -m, read by mirage-rs in a fixed order; throws
* UsageError for the first value grep refuses, in its words. Mirrors
* Python's `scan_values`.
*/
export function scanValues(fl) {
return scanned('grep', fl.texts(...SCANNED));
}
/**
* -m as mirage-rs reads it, null for no limit; grep checks it after the
* matchers, so a bad count yields to conflicting ones. Throws UsageError
* with grep's refusal. Mirrors Python's `max_count`.
*/
export function maxCount(fl) {
return scanned('grep', fl.texts('max_count')).max_count;
}
/**
* Whether grep walks the directories it meets: -r, -R or a -d naming
* recurse, the last of the three on the line winning (grep 3.11). A -d word
* is read by the scan the handler reads (`scanValues`), so the gate and the
* run share it; a line that scan refuses walks nothing, since grep refuses
* it before reading a file. Mirrors Python's `recurses`.
*/
export function recurses(fl) {
const last = fl.texts('recursive', 'dereference_recursive', 'directories').at(-1);
if (last === undefined)
return false;
if (last[0] !== 'directories')
return true;
try {
return scanValues(fl).directories === 'recurse';
}
catch (err) {
if (!(err instanceof UsageError))
throw err;
console.debug(`grep walks nothing past a refused option: ${err.message}`);
return false;
}
}
/**
* The line between context groups; null under --no-group-separator. The two
* options set one variable, so the later one wins.
*/
function groupSeparator(fl) {
let separator = '--';
for (const name of fl.typedOrder('group_separator', 'no_group_separator')) {
if (name === 'group_separator')
separator = fl.asStr(name) ?? '';
else if (fl.asBool(name))
separator = null;
}
return separator;
}
/** -i (or -y) unless a later --no-ignore-case turns it off. */
function ignoresCase(fl) {
const names = fl.typedOrder('ignore_case', 'no_ignore_case').filter((name) => fl.asBool(name));
return names[names.length - 1] === 'ignore_case';
}
/**
* What grep warns once for the line, before it reads a file: an obsolete -u,
* then the pattern's compile-time warnings. Mirrors Python's `line_warnings`.
*/
export function lineWarnings(fl, matcher) {
return ((fl.asBool('unix_byte_offsets') ? UNIX_BYTE_OFFSETS : '') +
(matcher === null ? '' : patternWarnings(matcher)));
}
/** The winning filename flag: true for -H, false for -h, null for neither. */
// [-l, -L]: which file-listing mode wins, the later one on the line. GNU's
// -l and -L set one variable (`list_files`) so the last typed wins: `grep -l
// -L` lists the files WITHOUT a match and `grep -L -l` the ones with one
// (measured on grep 3.11).
export function listingMode(fl) {
let winner = null;
for (const name of fl.typedOrder('files_with_matches', 'files_without_match')) {
if (fl.asBool(name))
winner = name;
}
return [winner === 'files_with_matches', winner === 'files_without_match'];
}
export function filenameMode(fl) {
let mode = null;
for (const name of fl.typedOrder('with_filename', 'no_filename')) {
if (fl.asBool(name))
mode = name === 'with_filename';
}
return mode;
}
function reason(error) {
return fsStrerror(error) ?? (error instanceof Error ? error.message : String(error));
}
export function parseFlags(fl) {
const values = scanValues(fl);
const mode = values.binary_mode;
const filename = filenameMode(fl);
const [filesOnly, filesWithoutMatch] = listingMode(fl);
const directories = values.directories;
return {
binaryMode: mode,
recursive: directories === 'recurse',
// -R follows every link wherever it stands on the line, -r after it
// included (grep 3.11).
dereference: fl.asBool('dereference_recursive'),
skipDirectories: directories === 'skip',
skipDevices: values.skip_devices,
filters: {
fileGlobs: parseFileGlobs(fl),
excludeDir: fl.asList('exclude_dir'),
text: mode === 'text',
},
ignoreCase: ignoresCase(fl),
invert: fl.asBool('invert_match'),
lineNumbers: fl.asBool('line_number'),
byteOffsets: fl.asBool('byte_offset'),
countOnly: fl.asBool('count'),
filesOnly,
filesWithoutMatch,
wholeWord: fl.asBool('word_regexp'),
lineRegexp: fl.asBool('line_regexp'),
fixedString: fl.asBool('fixed_strings'),
// grep reads a basic expression unless -E or -P says otherwise; -G asks
// for the default explicitly.
syntax: matcherSyntax(fl),
onlyMatching: fl.asBool('only_matching'),
maxCount: maxCount(fl),
quiet: fl.asBool('quiet'),
noMessages: fl.asBool('no_messages'),
withFilename: filename === true,
noFilename: filename === false,
afterContext: values.after_context,
beforeContext: values.before_context,
nullData: fl.asBool('null_data'),
null: fl.asBool('null'),
initialTab: fl.asBool('initial_tab'),
groupSeparator: groupSeparator(fl),
label: fl.asStr('label') ?? GREP_STDIN_LABEL,
};
}
export async function grepGeneric(name, paths, texts, ctx, stat, readdir, stream) {
stat = stdinStat(stat);
const operandStream = stdinStream(stream, ctx.stdin);
stream = (path) => guardInput(operandStream(path), ctx);
const fl = new FlagView(ctx.flags, specOf('grep'));
// GNU refuses a bad option value in its option loop, before it looks for a
// pattern.
let f;
try {
f = parseFlags(fl);
}
catch (error) {
if (!(error instanceof Error))
throw error;
const exitCode = error instanceof UsageError ? error.exitCode : 2;
return [null, new IOResult({ exitCode, stderr: ENC.encode(error.message + '\n') })];
}
const resolution = await resolvePattern(name, texts, fl, stream);
if (resolution.error !== null || resolution.pattern === null)
return [
null,
new IOResult({
exitCode: 2,
stderr: ENC.encode(resolution.error ?? `${GREP_NO_PATTERN}\n`),
}),
];
if (resolution.neverMatch)
f.fixedString = false;
const utf8 = utf8Locale(ctx.env);
// mirage-rs compiles the list once for every input.
let searcher;
let warning;
try {
const [opened, warnings] = await openGrep(streamConfig(f, resolution.neverMatch ? null : resolution.pattern, utf8));
searcher = opened;
warning = fl.asBool('unix_byte_offsets')
? concat([ENC.encode(UNIX_BYTE_OFFSETS), warnings])
: warnings;
}
catch (error) {
if (!(error instanceof UsageError))
throw error;
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode(error.message + '\n') })];
}
const search = (source, label, show, fileIO, afterOutput = false, size = null) => streamInput(source, searcher, label, show, fileIO, afterOutput, size, ctx.signal);
// The stream is released however the output ends, read or not.
const owned = (output) => new OutputStream(output[Symbol.asyncIterator](), () => {
searcher.close();
return Promise.resolve();
});
const io = warning.byteLength > 0 ? new IOResult({ stderr: warning }) : new IOResult();
io.exitCode = 1;
const first = paths[0];
if (first === undefined) {
try {
const source = guardInput(resolveSource(ctx.stdin), ctx);
return [
owned(search(source, f.label ?? GREP_STDIN_LABEL, f.withFilename && !f.noFilename, io)),
io,
];
}
catch (error) {
searcher.close();
if (!(error instanceof Error))
throw error;
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode(error.message + '\n') })];
}
}
const mounts = mountsOf(ctx);
const rd = mountParentReaddir((p) => readdir(toPathspec(p)), mounts, mountRoot(ctx));
const st = mountParentStat((p) => stat(toPathspec(p)), mounts);
// The walk meets links as entries: -r leaves them, -R reads what each one
// leads to, and a directory it reaches again is a loop.
const follow = f.recursive && f.dereference === true ? (linksOf(ctx) ?? null) : null;
const walkedSt = (p) => followedStat(st, follow, p);
let failed = false;
const notices = [];
let matched = false;
let printed = false;
function warn(message) {
failed = true;
io.exitCode = exitCodeFor(matched, failed, f.quiet);
if (!f.noMessages)
notices.push(ENC.encode(message + '\n'));
}
function updateStatus(fileIO) {
io.exitCode = exitCodeFor(matched || fileIO.exitCode === 0, failed, f.quiet);
}
async function* scan(p, walked = false, chain = []) {
let info;
let entries = [];
try {
// The probes below go by `virtual`, which cannot carry the walk's
// verdict on an operand it refused.
if (p.walkError !== null)
throw walkRefusal(p);
info = isStdin(p) ? await stat(p) : await (walked ? walkedSt : st)(p.virtual);
if (follow !== null && info.type === FileType.DIRECTORY) {
const here = follow.resolve(p.virtual);
if (chain.includes(here)) {
if (!f.noMessages)
notices.push(ENC.encode(`${name}: ${p.rawPath}: warning: recursive directory loop\n`));
return;
}
chain = [...chain, here];
}
if (info.type === FileType.DIRECTORY && f.recursive)
entries = await rd(p.virtual);
}
catch (error) {
if (!isWalkError(error))
throw error;
warn(`${name}: ${p.rawPath}: ${reason(error)}`);
return;
}
if (walked && follow !== null && info.type === FileType.SYMLINK) {
warn(`${name}: ${p.rawPath}: ${posixPhrase('ENOENT')}`);
return;
}
if (info.type === FileType.DIRECTORY) {
if (f.skipDirectories === true)
return;
if (!f.recursive) {
warn(`${name}: ${p.rawPath}: Is a directory`);
// GNU 3.11 still lists it under -L: nothing was read from it, so
// nothing in it matched. -q suppresses the row like every other
// normal output.
if (f.filesWithoutMatch && !f.quiet)
yield ENC.encode(p.rawPath + nameEnd(f));
return;
}
for (const entry of entries) {
const child = new PathSpec({
virtual: entry,
directory: entry,
vfsPath: stripSlash(entry),
rawPath: respellOne(entry, p.virtual, p.rawPath),
});
if (!dirAdmitted(entry, f.filters)) {
let probe;
try {
probe = await walkedSt(entry);
}
catch (error) {
if (!isWalkError(error))
throw error;
warn(`${name}: ${child.rawPath}: ${reason(error)}`);
continue;
}
if (probe.type === FileType.DIRECTORY)
continue;
}
yield* scan(child, true, chain);
if (f.quiet && matched)
break;
}
return;
}
if (walked && info.type !== FileType.FILE)
return;
if (f.skipDevices === true && DEVICE_TYPES.has(info.type) && !isStdin(p))
return;
if (walked && !f.filters.text && BINARY_EXTENSIONS.has(getExtension(p.virtual) ?? ''))
return;
if (!fileAdmitted(p.virtual, f.filters))
return;
const fileIO = new IOResult({ exitCode: 1 });
try {
try {
const show = !f.noFilename && (f.withFilename || walked || paths.length > 1);
for await (const chunk of search(stream(p), operandLabel(p, f.label ?? GREP_STDIN_LABEL), show, fileIO, printed, info.type === FileType.FILE ? (info.size ?? null) : null)) {
updateStatus(fileIO);
printed = true;
yield chunk;
}
}
finally {
matched ||= fileIO.exitCode === 0;
// A -P search that gave up ends its file with an error.
failed ||= fileIO.exitCode === 2;
updateStatus(fileIO);
if (fileIO.stderr !== null)
notices.push(await materialize(fileIO.stderr));
}
}
catch (error) {
if (!isFsError(error))
throw error;
warn(`${name}: ${p.rawPath}: ${reason(error)}`);
}
}
async function* run() {
try {
for (const path of paths) {
try {
yield* scan(path);
}
catch (err) {
if (!(err instanceof WalkLimitError))
throw err;
warn(`grep: ${err.message}`);
}
if (f.quiet && matched)
break;
}
}
finally {
if (notices.length > 0)
io.stderr = concat([await materialize(io.stderr), ...notices]);
}
}
return [owned(run()), io];
}
/**
* A grep line walks, and reads, below the directories it names when it
* recurses (`recurses`). Mirrors Python's `facts`.
*/
export function facts(fl, _operands) {
const walks = recurses(fl);
return new LineFacts({ walks, readsSubtree: walks });
}
//# sourceMappingURL=grep.js.map