UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

322 lines • 12.1 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 { UsageError } from "../errors.js"; import { rstripSlash } from "../../utils/slash.js"; // globset's own words for a glob it cannot compile (ripgrep 14.1.1). export const UNCLOSED_CLASS = "unclosed character class; missing ']'"; export const UNCLOSED_ALTERNATES = "unclosed alternate group; missing '}' (maybe escape '{' with '[{]'?)"; export const UNOPENED_ALTERNATES = "unopened alternate group; missing '{' (maybe escape '}' with '[}]'?)"; export const NESTED_ALTERNATES = 'nested alternate groups are not allowed'; export const DANGLING_ESCAPE = "dangling '\\'"; /** * What one of ripgrep's path matchers says about a path: nothing, leave it * out, or keep it whatever the later filters would say (the ignore crate's * `Match`). */ export var Verdict; (function (Verdict) { Verdict["NONE"] = "none"; Verdict["IGNORE"] = "ignore"; Verdict["WHITELIST"] = "whitelist"; })(Verdict || (Verdict = {})); const SPECIAL = /[\\^$.|?*+()[\]{}\-/]/; function escapeChar(ch) { return SPECIAL.test(ch) ? `\\${ch}` : ch; } // What a class gives `lo`-`hi`, one character when the two are the same and, // folded, the other case of each ASCII letter in it: globset folds ASCII // letters only, so `--iglob é` never matches `É`, where a RegExp's `i` flag // would. function classPart(lo, hi, fold) { const out = [lo === hi ? escapeChar(lo) : escapeChar(lo) + '-' + escapeChar(hi)]; const [from, to] = [lo.charCodeAt(0), hi.charCodeAt(0)]; for (const [start, end, shift] of [ [0x41, 0x5a, 0x20], [0x61, 0x7a, -0x20], ]) { const [a, b] = [Math.max(from, start), Math.min(to, end)]; if (fold && a <= b) { const other = String.fromCharCode(a + shift); out.push(a === b ? other : other + '-' + String.fromCharCode(b + shift)); } } return out.join(''); } export function globError(glob, reason) { return new UsageError(`rg: error parsing glob '${glob}': ${reason}`); } // One `[...]` class as regex source, and where the text resumes. `shown` is // the glob as typed, for the refusal wording; `i` is just past the `[`. function classSource(shown, text, i, fold = false) { const negated = i < text.length && (text[i] === '!' || text[i] === '^'); if (negated) i += 1; const members = []; let first = true; while (i < text.length) { const ch = text[i] ?? ''; if (ch === ']' && !first) { return [(negated ? '[^' : '[') + members.join('') + ']', i + 1]; } first = false; if (i + 2 < text.length && text[i + 1] === '-' && text[i + 2] !== ']') { members.push(classPart(ch, text[i + 2] ?? '', fold)); i += 3; continue; } members.push(classPart(ch, ch, fold)); i += 1; } throw globError(shown, UNCLOSED_CLASS); } // A run of `*` as regex source, and where the glob resumes. `**` recurses // only as a whole path component (at the start or after `/`, and at the end // or before `/`); anywhere else it is two plain stars, which never cross `/` // (globset with literal separators). function starSource(glob, i, stars) { const start = i - stars; if (stars < 2) return ['[^/]*', i]; const atStart = start === 0 || glob[start - 1] === '/'; const atEnd = i === glob.length || glob[i] === '/'; if (!(atStart && atEnd)) return ['[^/]*', i]; if (i === glob.length) return ['.*', i]; // `**/` (leading or between components) is zero or more whole // components; the `/` it ends with is part of it. return ['(?:.*/)?', i + 1]; } // Regex source for `text`, one alternative of `glob` or all of it; `nested` // says whether `text` already sits inside `{...}`. function globBody(glob, text, nested, fold = false) { const literal = (ch) => fold && /^[A-Za-z]$/.test(ch) ? `[${classPart(ch, ch, fold)}]` : escapeChar(ch); const out = []; let i = 0; while (i < text.length) { const ch = text[i] ?? ''; if (ch === '\\') { if (i + 1 >= text.length) throw globError(glob, DANGLING_ESCAPE); out.push(literal(text[i + 1] ?? '')); i += 2; } else if (ch === '*') { let j = i; while (j < text.length && text[j] === '*') j += 1; const [source, next] = starSource(text, j, j - i); out.push(source); i = next; } else if (ch === '?') { out.push('[^/]'); i += 1; } else if (ch === '[') { const [source, next] = classSource(glob, text, i + 1, fold); out.push(source); i = next; } else if (ch === '{') { if (nested) throw globError(glob, NESTED_ALTERNATES); const [close, parts] = alternates(glob, text, i + 1); out.push('(?:' + parts.map((part) => globBody(glob, part, true, fold)).join('|') + ')'); i = close + 1; } else if (ch === '}' && !nested) { throw globError(glob, UNOPENED_ALTERNATES); } else { out.push(literal(ch)); i += 1; } } return out.join(''); } // The alternatives of one `{...}` group and the index of its `}`; `i` is // just past the `{`. function alternates(glob, text, i) { const parts = []; let current = []; while (i < text.length) { const ch = text[i] ?? ''; if (ch === '\\' && i + 1 < text.length) { current.push(text.slice(i, i + 2)); i += 2; continue; } if (ch === '[') { const [, end] = classSource(glob, text, i + 1); current.push(text.slice(i, end)); i = end; continue; } if (ch === '{') throw globError(glob, NESTED_ALTERNATES); if (ch === '}') { parts.push(current.join('')); return [i, parts]; } if (ch === ',') { parts.push(current.join('')); current = []; } else { current.push(ch); } i += 1; } throw globError(glob, UNCLOSED_ALTERNATES); } /** * ripgrep's glob syntax as one anchored matcher: globset with literal * separators and backslash escapes, which is what ripgrep builds every `-g` * and `--type` glob with. `*` and `?` stay inside one path component, `**` * spans components, `[...]` and `{a,b}` are classes and alternatives. * `shown` is the glob as the line typed it, when that is not `glob` itself * (a `-g` line after ignore-rule rewriting), for the refusal wording. */ export function compileGlob(glob, caseInsensitive = false, shown) { return new RegExp(`^(?:${globBody(shown ?? glob, glob, false, caseInsensitive)})$`); } /** * Parse one `-g` value the way the ignore crate parses a gitignore line, or * null for a line that says nothing (empty, or a `#` comment). A glob with * no `/` in it matches at any depth; one with a `/`, or a leading `/`, is * anchored to the path as walked. A trailing `/` limits it to directories, * and `dir/**` keeps everything below the directory but not the directory * itself. */ export function overrideGlob(line, caseInsensitive) { if (line.startsWith('#')) return null; if (!line.endsWith('\\ ')) line = line.trimEnd(); if (line === '') return null; const shown = line; let keep = true; let absolute = false; if (line.startsWith('\\!') || line.startsWith('\\#')) { line = line.slice(1); absolute = line.startsWith('/'); } else { if (line.startsWith('!')) { keep = false; line = line.slice(1); } if (line.startsWith('/')) { line = line.slice(1); absolute = true; } } let dirOnly = false; if (line.endsWith('/')) { dirOnly = true; line = line.slice(0, -1); if (line.endsWith('\\')) line = line.slice(0, -1); } let actual = line; if (!absolute && !line.includes('/') && !(actual.startsWith('**/') || actual === '**')) { actual = '**/' + actual; } if (actual.endsWith('/**')) actual += '/*'; return { matcher: compileGlob(actual, caseInsensitive, shown), keep, dirOnly }; } /** * ripgrep's `-g`/`--iglob` matcher (the ignore crate's `Override`). The last * glob that matches a path decides it, a `!` one dropping it and a plain one * keeping it whatever the type and hidden filters would say. Once any plain * glob exists, a file no glob matches is dropped, while a directory no glob * matches is still walked. `iglobs` come after every `-g`, whatever order * the line gave them. */ export class Overrides { globs; keeps; constructor(globs, iglobs, caseInsensitive) { const parsed = [ ...globs.map((g) => overrideGlob(g, caseInsensitive)), ...iglobs.map((g) => overrideGlob(g, true)), ]; this.globs = parsed.filter((g) => g !== null); this.keeps = this.globs.some((g) => g.keep); } // What the globs say about one walked path (`walkCandidate`'s form). verdict(path, isDir) { if (this.globs.length === 0) return Verdict.NONE; for (let i = this.globs.length - 1; i >= 0; i--) { const glob = this.globs[i]; if (glob === undefined || (glob.dirOnly && !isDir)) continue; if (glob.matcher.test(path)) return glob.keep ? Verdict.WHITELIST : Verdict.IGNORE; } if (this.keeps && !isDir) return Verdict.IGNORE; return Verdict.NONE; } } /** * --ignore-file's rules: gitignore lines rooted at the working directory, the * last one that matches a walked path deciding it, a plain one ignoring it and * a `!` one keeping it. Mirrors Python's IgnoreRules. */ export class IgnoreRules { globs; constructor(lines = []) { this.globs = lines .map((line) => overrideGlob(line, false)) .filter((g) => g !== null); } /** Whether the files held no rule. */ get empty() { return this.globs.length === 0; } /** What the rules say about one walked path (`walkCandidate`). */ verdict(path, isDir) { for (let i = this.globs.length - 1; i >= 0; i--) { const glob = this.globs[i]; if (glob === undefined || (glob.dirOnly && !isDir)) continue; if (glob.matcher.test(path)) return glob.keep ? Verdict.IGNORE : Verdict.WHITELIST; } return Verdict.NONE; } } /** * The path ripgrep matches a walked entry's globs against: the walked path * as printed, with a leading `./` dropped and the working directory stripped * off an absolute one, which is what the ignore crate's `strip` does with * the override root (the cwd). */ export function walkCandidate(shown, cwd) { const path = shown.startsWith('./') ? shown.slice(2) : shown; if (!path.startsWith('/')) return path; const root = rstripSlash(cwd); if (root === '') return path.replace(/^\/+/, ''); if (path.startsWith(root + '/')) return path.slice(root.length + 1); return path; } //# sourceMappingURL=rg_glob.js.map