@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
275 lines • 12 kB
JavaScript
import { concat } from "../../../../utils/bytes.js";
import { IOResult } from "../../../../io/types.js";
import { YieldBudget } from "../../../../io/yield_budget.js";
import { byteView, encodeText, fromByteView, utf8Locale } from "../../../../shell/bytes.js";
import { compareCodePoints } from "../../../../utils/sort.js";
import { RegexSyntax } from "../../../builtin/types.js";
import { RegexError } from "../../../../rs/errors.js";
import { posixEscape, posixRegex } from "../../../../rs/regex.js";
import { FlagView } from "../../../spec/flag_view.js";
import { mountsOf } from "../../../builtin/utils/links.js";
import { requireWorkTree } from "./discover.js";
import { AmbiguousArgumentError, FATAL_EXIT, GitError, InvalidRevisionNameError, PatternCompileError, UsageError, } from "./errors.js";
import { readIndex } from "./index_file.js";
import { exists, isLink, readOptional } from "./io.js";
import { pathspecPatterns, pathspecSelects, repoRelative } from "./pathspec.js";
import { quotePath, relativePath } from "./render.js";
import { EXECUTABLE, EXECUTABLE_MODE, REGULAR, REGULAR_MODE } from "./constants.js";
import { blobData } from "./patch.js";
import { configBool } from "./repo.js";
import { opened } from "./session.js";
import { BINARY_SNIFF } from "./summary.js";
import { resolveObject, unwrapped } from "./revparse.js";
import { placeNamed, shownEntries } from "./seen.js";
import { resolveTree, treeEntries } from "./tree.js";
import { checkSwitches, splitMarked, startPoint, verbUsage, requireVfs } from "./util.js";
// A line test for one pattern; under -w only a match that consumes something
// counts, so an empty match does not select the line. Mirrors Python's
// _line_matcher.
function lineMatcher(pattern, nonempty) {
if (!nonempty)
return async (line, signal) => (await pattern.search(line, 0, signal)) !== null;
return async (line, signal) => (await pattern.all(line, signal)).some((span) => span[0] != null && span[0][0] !== span[0][1]);
}
/** Compile patterns once, retaining Git's option precedence and errors. */
function parseFlags(fl, patterns, origin, utf8) {
let syntax = RegexSyntax.BASIC;
let fixed = false;
for (const [name] of fl.occurrences('basic_regexp', 'extended_regexp', 'fixed_strings')) {
syntax = name === 'extended_regexp' ? RegexSyntax.EXTENDED : RegexSyntax.BASIC;
fixed = name === 'fixed_strings';
}
const ignoreCase = fl.asBool('ignore_case');
const wholeWord = fl.asBool('word_regexp');
const compiled = [];
for (const value of patterns) {
for (const part of value.split('\n')) {
const view = byteView(part, utf8);
let pattern;
try {
pattern = posixRegex(fixed ? posixEscape(view) : view, fixed || syntax === RegexSyntax.EXTENDED, 'posix', ignoreCase, utf8, false, wholeWord);
}
catch (err) {
if (err instanceof RegexError)
throw new PatternCompileError(origin, part, err.message);
throw err;
}
compiled.push(lineMatcher(pattern, wholeWord));
}
}
const listing = fl.asBool('files_without_match')
? 'files_without_match'
: fl.asBool('files_with_matches')
? 'files_with_matches'
: '';
let filename = true;
for (const name of fl.typedOrder('h', 'H'))
filename = name === 'H';
let binary = 'binary';
for (const name of fl.typedOrder('text', 'args_I'))
binary = name === 'text' ? 'text' : 'skip';
return {
patterns: compiled,
invert: fl.asBool('invert_match'),
numbers: fl.asBool('line_number'),
count: fl.asBool('count'),
listing,
quiet: fl.asBool('quiet'),
filename,
nul: fl.asBool('null'),
binary,
utf8,
};
}
/** Select and render one file, preserving its content bytes. */
async function searched(data, label, flags, signal) {
const binary = data.subarray(0, BINARY_SNIFF).includes(0) && flags.binary !== 'text';
if (binary && flags.binary === 'skip')
return [new Uint8Array(), false];
const lines = byteView(data, flags.utf8).split('\n');
if (lines.at(-1) === '')
lines.pop();
const selected = [];
const budget = new YieldBudget(signal);
for (const [index, line] of lines.entries()) {
await budget.run();
let hit = false;
for (const matches of flags.patterns) {
if (await matches(line, signal)) {
hit = true;
break;
}
}
if (hit !== flags.invert)
selected.push([index + 1, line]);
}
const matched = selected.length > 0;
const found = flags.listing === 'files_without_match' ? !matched : matched;
if (!found || flags.quiet)
return [new Uint8Array(), found];
const sep = flags.nul ? '\0' : ':';
if (flags.listing)
return [encodeText(label + (flags.nul ? '\0' : '\n')), true];
const prefix = flags.filename ? label + sep : '';
if (flags.count)
return [encodeText(prefix + String(selected.length) + '\n'), true];
if (binary)
return [encodeText(`Binary file ${label} matches\n`), true];
return [
concat(selected.flatMap(([lineNumber, line]) => [
encodeText(prefix + (flags.numbers ? String(lineNumber) + sep : '')),
fromByteView(line, flags.utf8),
encodeText('\n'),
])),
true,
];
}
/**
* The searchable leaves of a tree-ish the session sees, keyed relative to it,
* or a directly named blob.
*/
async function searchEntries(repo, name) {
const obj = await unwrapped(repo, await resolveObject(repo, name), name);
if (obj.type === 'blob')
return new Map([['', { mode: REGULAR_MODE, oid: obj.oid }]]);
const tree = await resolveTree(repo, name, obj);
return shownEntries(repo, await treeEntries(repo, tree), await placeNamed(repo, name, tree));
}
/** Search tracked working files, index blobs, or named historical trees. */
export async function grep(_accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
checkSwitches(ctx, texts.slice(0, 1));
let words = [...texts];
let patterns = fl.asList('e');
const origin = patterns.length ? '-e option' : 'command line';
let paths;
let marked;
if (patterns.length) {
;
[words, paths] = splitMarked(texts, ctx.argv ?? []);
marked = (ctx.argv ?? []).includes('--');
if (words.at(-1) === '--')
words.pop();
}
else {
const pattern = words.shift();
if (pattern === undefined) {
if (fl.asBool('h'))
throw new UsageError(verbUsage(ctx), '');
throw new GitError('no pattern given');
}
patterns = [pattern];
marked = words.includes('--');
const cut = marked ? words.indexOf('--') : words.length;
paths = words.slice(cut + 1);
words = words.slice(0, cut);
}
const flags = parseFlags(fl, patterns, origin, utf8Locale(ctx.env));
const repo = await opened(ctx, fl);
const start = startPoint(fl).virtual;
const prefix = repoRelative(repo.location, start, '.');
const cached = fl.asBool('cached');
const sources = [];
for (const [index, word] of words.entries()) {
if (word.startsWith('-'))
throw new GitError(`option '${word}' must come before non-option arguments`);
try {
sources.push([word, await searchEntries(repo, word)]);
}
catch (err) {
if (!(err instanceof AmbiguousArgumentError || err instanceof InvalidRevisionNameError))
throw err;
if (marked)
throw new GitError(`unable to resolve revision: ${word}`);
const relative = repoRelative(repo.location, start, word);
if (!/[*?[]/.test(word) && !(await exists(repo.vfs, repo.location.worktree.join(relative))))
throw new AmbiguousArgumentError(word);
console.debug('Git grep operand is a pathspec:', word);
paths = [...words.slice(index), ...paths];
break;
}
}
if (sources.length && cached)
throw new GitError('both --cached and trees are given');
if (!sources.length && !cached) {
await requireWorkTree(requireVfs(ctx), repo.location, fl.asPath('work_tree') !== undefined);
}
const specs = pathspecPatterns(repo.location, start, paths);
if (!specs.length)
specs.push(prefix);
const fully = await configBool(repo, 'core.quotepath', true);
if (!sources.length) {
const state = await readIndex(repo, repo.vfs);
const entries = new Map([...state.entries].map(([path, entry]) => [
path,
{ mode: entry.mode.toString(8), oid: entry.oid },
]));
if (!cached) {
for (const [path, conflict] of state.conflicts) {
for (const entry of [conflict.this, conflict.other, conflict.ancestor]) {
if (entry !== null && [REGULAR, EXECUTABLE].includes(entry.mode)) {
entries.set(path, { mode: entry.mode.toString(8), oid: entry.oid });
break;
}
}
}
}
sources.push(['', shownEntries(repo, entries)]);
}
const mounts = mountsOf(ctx);
const out = [];
let found = false;
const read = new Map();
for (const [revision, entries] of sources) {
for (const [path, entry] of [...entries].sort(([a], [b]) => compareCodePoints(byteView(a), byteView(b)))) {
// Every source is what the session sees already, at the place its
// tree stands.
if (![REGULAR_MODE, EXECUTABLE_MODE].includes(entry.mode) ||
(path && !pathspecSelects(path, specs)))
continue;
const target = repo.location.worktree.join(path);
if (mounts?.walkLimit !== undefined) {
// A blob is read from the repository, a working-tree file from the
// mount serving it, and each mount counts its own.
const source = (revision || cached ? repo.location.gitdir : target).virtual;
const limit = mounts.walkLimit(source);
const root = mounts.rootOf(source);
const used = read.get(root) ?? 0;
if (limit >= 0 && used >= limit) {
// Past the limit the search stops, and what it found so far
// stands, as git keeps what it printed before a fatal.
return [
concat(out),
new IOResult({
exitCode: FATAL_EXIT,
stderr: encodeText(`fatal: stopped after ${String(limit)} files (walk_limit); narrow the pathspec\n`),
}),
];
}
read.set(root, used + 1);
}
let data;
if (revision || cached)
data = await blobData(repo, entry);
else {
if (await isLink(repo.vfs, target))
continue;
data = await readOptional(repo.vfs, target);
if (data === null)
continue;
}
let label = relativePath(path || revision, prefix);
if (!flags.nul)
label = quotePath(label, false, fully);
if (revision && path)
label = revision + ':' + label;
const [rendered, hit] = await searched(data, label, flags, ctx.signal);
found ||= hit;
if (hit && flags.quiet)
return [null, new IOResult()];
out.push(rendered);
}
}
return [concat(out), new IOResult({ exitCode: found ? 0 : 1 })];
}
//# sourceMappingURL=grep.js.map