UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

257 lines • 11 kB
import { mergePatternList } from "../grep_pattern.js"; import { parseFlags as grepFlags } from "./grep.js"; import { parseFlags as rgFlags } from "./rg.js"; import { UsageError } from "../../errors.js"; import { osErrorText } from "../rg_scan.js"; import { dispatchStat } from "../utils/paths.js"; import { isStdin, resolveSource } from "../utils/stream.js"; import { specOf } from "../../spec/index.js"; import { FlagView, flagOccurrences } from "../../spec/flag_view.js"; import { loadFailure } from "../../../core/jq/index.js"; import { IOResult, materialize } from "../../../io/types.js"; import { decodeText, encodeText } from "../../../shell/bytes.js"; import { FileType } from "../../../types.js"; import { eisdir, isEisdir, isFsError, strerrorOf } from "../../../errors/fs.js"; import { fsErrorLine } from "../../../errors/render.js"; // The commands whose program files the executor reads before routing and // lowers to their inline form. jq reads its -f file itself, after its option // loop (OWN_OPTION_LOOP), so an option it refuses is reported first; // FILE_KEYS still names its dest, so routing leaves the file out. export const PROGRAM_FILE_COMMANDS = new Set(['grep', 'rg', 'zgrep', 'sed', 'awk']); // ripgrep reads patterns from stdin once, and refuses both a second `-f -` // and a `-` operand after it, exit 2 (14.1.1). const RG_STDIN_REREAD = 'rg: error reading -f/--file from stdin: stdin has already been consumed\n'; const RG_STDIN_SEARCHED = 'rg: error: attempted to read patterns from stdin while also searching stdin\n'; // The dest each command's spec gives its program file. export const FILE_KEYS = { grep: 'file', rg: 'file', zgrep: 'file', sed: 'f', awk: 'f', jq: 'from_file', }; /** * A program file the command cannot read, in its own words and code. * * sed could not open the file, exit 4. mawk quotes the name after `cannot * open`, and a file it opened and then failed to read, which is how a * directory fails, is a bare `read error`. jq could not open it, and calls a * directory one in words of its own. ripgrep appends the errno, with no space * after the colon for a failed read. zgrep copies each pattern file with cat, * so the line is cat's. grep names it as any operand. Every other code is 2. * Pinned on debian:stable-slim (grep 3.11, sed 4.9, mawk 1.3.4, jq 1.7.1, * ripgrep 14.1.1, gzip 1.13). Mirrors Python's program_file_refusal. */ export function programFileRefusal(name, path, err) { const shown = path.rawPath !== '' ? path.rawPath : path.virtual; const strerror = strerrorOf(err); const readFailed = isEisdir(err); if (name === 'sed') return [`sed: couldn't open file ${shown}: ${strerror}\n`, 4]; if (name === 'awk') { if (readFailed) return [`awk: read error (${strerror})\n`, 2]; return [`awk: cannot open "${shown}" (${strerror})\n`, 2]; } if (name === 'jq') return [`jq: ${loadFailure(shown, err)}\n`, 2]; if (name === 'rg') return [`rg: ${shown}:${readFailed ? '' : ' '}${osErrorText(err)}\n`, 2]; if (name === 'zgrep') return [fsErrorLine('cat', path, err), 2]; return [fsErrorLine(name, path, err), 2]; } /** * One program file's bytes, read through the dispatcher. A directory opens and * fails at its read, which a keyed store's own read cannot tell from nothing * being there, so a stat goes first; sed alone reads a directory as an empty * script (sed 4.9). Mirrors Python's read_program_file. */ export async function readProgramFile(name, path, dispatch) { if ((await dispatchStat(dispatch)(path)).type === FileType.DIRECTORY) { if (name === 'sed') return new Uint8Array(); throw eisdir(path); } const [data] = await dispatch('read', path); return materialize(data); } /** * The invocation's program files, or an empty list for inline programs. * grep's --exclude-from files count: they are read the same way, once, before * routing. */ export function programFiles(name, bag) { const fl = new FlagView(bag, specOf(name)); const files = fl.asPaths(FILE_KEYS[name] ?? 'f'); return name === 'grep' ? [...files, ...fl.asPaths('exclude_from')] : files; } /** * grep's --exclude-from files read into the globs they hold. GNU grep takes * one glob per line, trailing blanks dropped and blank lines skipped, and `-` * is stdin (`source`, shared with -f). The globs stay under `exclude_from`, * where the option stood among --include and --exclude. A file grep cannot * read stops it before it searches anything, exit 2. Returns the flags, * whether stdin was read, and the refusal. Mirrors Python's * lower_exclude_from. */ /** * Which of grep's -f and --exclude-from reads stdin: the first of them on the * line to name it; the other then reads nothing. */ function stdinReader(fl) { const paths = new Map([ ['file', fl.asPaths('file').values()], ['exclude_from', fl.asPaths('exclude_from').values()], ]); for (const [name] of fl.occurrences('file', 'exclude_from')) { const next = paths.get(name)?.next(); if (next !== undefined && next.done !== true && isStdin(next.value)) return name; } return null; } async function lowerExcludeFrom(fl, bag, source, dispatch) { const globs = []; let consumed = false; const reader = stdinReader(fl); for (const path of fl.asPaths('exclude_from')) { let data; try { if (isStdin(path)) { data = reader === 'file' ? new Uint8Array() : await materialize(source); consumed = reader !== 'file'; } else { data = await readProgramFile('grep', path, dispatch); } } catch (err) { if (!isFsError(err)) throw err; const line = fsErrorLine('grep', path, err); return [bag, consumed, new IOResult({ exitCode: 2, stderr: new TextEncoder().encode(line) })]; } for (const line of decodeText(data).split('\n')) { let end = line.length; while (end > 0 && ' \t\n\v\f\r'.includes(line.charAt(end - 1))) end--; if (end > 0) globs.push(line.slice(0, end)); } } const lowered = { ...bag, exclude_from: globs }; flagOccurrences(lowered).push(...flagOccurrences(bag)); return [lowered, consumed, null]; } /** * grep's or rg's refusal of an option value on the line, or null. Both check * every value in their option loop and answer --help and --version only once * it is done (grep 3.11, ripgrep 14.1.1: `grep -D bogus -V` and `rg --sort * bogus --version` are refused). The pattern is not compiled yet, as neither * has by then. Mirrors Python's `value_refusal`. */ export function valueRefusal(name, bag) { const fl = new FlagView(bag, specOf(name)); try { if (name === 'grep') grepFlags(fl); else if (name === 'rg') rgFlags(fl); } catch (err) { if (!(err instanceof UsageError)) throw err; return new IOResult({ exitCode: err.exitCode, stderr: encodeText(`${err.message}\n`) }); } return null; } /** Read program files once before input routing or traversal fan-out. * Lower to the inline form so every native sub-run sees the same program, * including when reading it consumed stdin. Pinned against debian:stable-slim * (grep 3.11, sed 4.9, ripgrep 14.1.1). * `operands` are the path operands, which rg checks for a `-` once `-f -` has * read stdin. */ export async function prepareProgram(name, texts, bag, stdin, dispatch, operands = []) { let fl = new FlagView(bag, specOf(name)); const source = resolveSource(stdin); let consumed = false; if (name === 'grep' && fl.asPaths('exclude_from').length > 0) { const [lowered, read, refusal] = await lowerExcludeFrom(fl, bag, source, dispatch); if (refusal !== null) return [texts, bag, stdin, refusal]; bag = lowered; consumed = read; fl = new FlagView(bag, specOf(name)); } const key = FILE_KEYS[name] ?? 'f'; const files = fl.asPaths(key); if (files.length === 0) return [texts, bag, consumed ? source : stdin, null]; // Only a literal `-` takes stdin in ripgrep's sense: `-f /dev/stdin` reads // the same bytes as a file, so neither refusal follows from it. let taken = false; const pieces = []; for (const path of files) { try { if (isStdin(path)) { if (name === 'rg' && path.rawPath === '-') { if (taken) { return [ texts, bag, stdin, new IOResult({ exitCode: 2, stderr: new TextEncoder().encode(RG_STDIN_REREAD) }), ]; } taken = true; } pieces.push(await materialize(source)); consumed = true; } else { pieces.push(await readProgramFile(name, path, dispatch)); } } catch (err) { if (!isFsError(err)) throw err; // Match GNU's fatal script-open status; ordinary input-file failures // still belong to the native command handlers. const [line, exitCode] = programFileRefusal(name, path, err); return [texts, bag, stdin, new IOResult({ exitCode, stderr: new TextEncoder().encode(line) })]; } } if (taken && operands.some((p) => p.rawPath === '-')) { return [ texts, bag, stdin, new IOResult({ exitCode: 2, stderr: new TextEncoder().encode(RG_STDIN_SEARCHED) }), ]; } const out = Object.fromEntries(Object.entries(bag).filter(([name]) => name !== key)); if (name === 'grep' || name === 'rg' || name === 'zgrep') { const expressions = fl.asList('regexp'); let pattern = expressions.length > 0 ? expressions.join('\n') : null; for (const data of pieces) pattern = mergePatternList(pattern, data); // An empty pattern-file list preserves grep's zero-pattern sentinel. out[key] = []; out.regexp = pattern === null ? [] : [pattern]; } else if (name === 'sed') { const expressions = fl.asList('e').values(); const scripts = pieces.map((data) => decodeText(data)).values(); out.e = fl .occurrences('e', 'f') .map(([kind]) => (kind === 'e' ? expressions : scripts).next().value ?? ''); } else { texts = [pieces.map((data) => decodeText(data)).join('\n'), ...texts]; } return [texts, out, consumed ? source : stdin, null]; } //# sourceMappingURL=program.js.map