UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

547 lines • 24.4 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 { callerSignal } from "../context/session_context.js"; import { dotglobActive, extglobActive, sessionVisibility } from "../context/session_context.js"; import { pathVisible } from "./hidden.js"; import { FileType, PathSpec } from "../types.js"; import { isFsError } from "../errors/fs.js"; import { GlobLimitError } from "../errors/types.js"; import { EXTGLOB_RE, fnmatch, patternParts, QUOTED_CHARS } from "./fnmatch.js"; import { Glob } from "../rs/glob.js"; import { rekey } from "./key_prefix.js"; import { rstripSlash } from "./slash.js"; import { compareCodePoints } from "./sort.js"; /** * Opt in by assigning a debug sink, as Python's module logger is off until * configured: a glob that lists nothing is routine, and stdout may carry data. */ export const logger = {}; export const GLOB_CHARS = ['*', '?', '[']; function isoDay(year, month, date) { const mm = String(month).padStart(2, '0'); const dd = String(date).padStart(2, '0'); return `${String(year)}-${mm}-${dd}`; } function isValidDate(year, month, date) { if (month < 1 || month > 12 || date < 1) return false; const d = new Date(Date.UTC(year, month - 1, date)); return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === date; } function parseFixedInt(s, expectedLength) { if (s?.length !== expectedLength || !/^\d+$/.test(s)) return null; return Number.parseInt(s, 10); } /** * Whether a glob names a date range a windowed lister can move to. * * The kit's `patternKinds` table holds one of these per kind: a glob it * answers true for reaches the lister and bypasses the index, and any other * glob is filtered out of the ordinary cached listing. */ export function hasGlobSpan(pattern) { return globSpan(pattern) !== null; } /** * Whether a glob starts with literal text a query can narrow on. * * The `patternKinds` twin of `hasGlobSpan` for a backend whose window is a * row cap rather than a date range: the literal prefix becomes a prefix match * in the query, so the cap covers the region the line named instead of the * head of the table. */ export function hasGlobPrefix(pattern) { return globPrefix(pattern) !== ''; } /** * The literal text a glob starts with, before its first metacharacter. * * A quoted glob character travels under a private mark and stands for that * character literally, so the marks are restored here: `'*'ab*` asks for * names starting with a real star. */ export function globPrefix(pattern) { if (!pattern) return ''; let metaIndex = -1; for (const ch of GLOB_CHARS) { const idx = pattern.indexOf(ch); if (idx !== -1 && (metaIndex === -1 || idx < metaIndex)) metaIndex = idx; } const opener = extglobActive() ? pattern.search(EXTGLOB_RE) : -1; if (opener !== -1 && (metaIndex === -1 || opener < metaIndex)) metaIndex = opener; if (metaIndex === -1) return ''; return unmarkGlobs(pattern.slice(0, metaIndex)); } /** * The literal prefix a glob puts on the stem of a leaf name. * * A leaf is a stem plus one of the renderer's suffixes, so a literal that has * run into a suffix says nothing about the stem and the part that ran in is * dropped: `12*.md` narrows to `12`, and `doc-1.m*` narrows to `doc-1` rather * than asking for stems that start `doc-1.m`. Only a tail that spells the head * of a suffix is dropped, which is what keeps a stem that contains a dot: * `acct.2026*` narrows to `acct.2026`, where cutting at the first dot would * narrow to `acct` and let the rows nobody asked for eat the window. */ export function globStemPrefix(pattern, suffixes) { const literal = globPrefix(pattern); let reached = 0; for (const suffix of suffixes) { for (let size = 1; size <= suffix.length; size += 1) { if (literal.endsWith(suffix.slice(0, size))) reached = Math.max(reached, size); } } return literal.slice(0, literal.length - reached); } /** * The half-open range of dates a date-prefixed glob asks for. * * The literal prefix before the first metacharacter is read as a year, a * month or a day, so `2026-*` spans a year and `2026-01-05*` one day. This is * what lets a windowed listing honour a glob instead of filtering its own * window: the backend moves the window to the span the line named. Dates are * floating `YYYY-MM-DD`, since a caller bucketing in a named time zone has to * build its own instants from them; UTC instants would shift the window by * the offset. */ export function globSpan(pattern) { return literalSpan(globPrefix(pattern)); } /** * The half-open range of dates a literal year, month or day names. * * The reading `globSpan` applies to a glob's literal prefix, for a caller * that cuts the prefix first; separators trailing the literal are ignored. */ export function literalSpan(literal) { if (literal === '') return null; const parts = literal.replace(/[_-]+$/, '').split('-'); if (parts.length === 1) { const year = parseFixedInt(parts[0], 4); if (year === null) return null; return [isoDay(year, 1, 1), isoDay(year + 1, 1, 1)]; } if (parts.length === 2) { const year = parseFixedInt(parts[0], 4); const month = parseFixedInt(parts[1], 2); if (year === null || month === null) return null; if (!isValidDate(year, month, 1)) return null; if (month === 12) return [isoDay(year, month, 1), isoDay(year + 1, 1, 1)]; return [isoDay(year, month, 1), isoDay(year, month + 1, 1)]; } if (parts.length === 3) { const year = parseFixedInt(parts[0], 4); const month = parseFixedInt(parts[1], 2); const date = parseFixedInt(parts[2], 2); if (year === null || month === null || date === null) return null; if (!isValidDate(year, month, date)) return null; const next = new Date(Date.UTC(year, month - 1, date) + 86400000); return [ isoDay(year, month, date), isoDay(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate()), ]; } return null; } // A quoted glob character keeps travelling as a character, under a // private mark, because bash tracks quoting per character and not per // word: `'*'?.txt` still globs, on the `?` alone, and matches only a // name starting with a literal star. A mark is one character wide, so // every length relation between a spec's virtual, directory, // vfsPath and rawPath keeps holding, and no mark is a glob // character, so `hasGlob` already answers "does this word still glob". // The marks are Unicode noncharacters, permanently unassigned and never // valid interchange text -- the same impossible input `brace.ts` assumes // away when it delimits its inert atoms with NUL. const GLOB_MARKS = Object.fromEntries(Object.entries(QUOTED_CHARS).map(([mark, ch]) => [ch, mark])); const GLOB_CHAR_OF = Object.fromEntries(Object.entries(GLOB_MARKS).map(([ch, mark]) => [mark, ch])); // One native pass, not a per-character rebuild: every expanded word is // marked and unmarked, so a JS-level loop made the cost quadratic in a // loop that grows one word (`while true; do export X=$X.; done`). const GLOB_CHAR_RE = /[*?[@+!()|]/g; const GLOB_MARK_RE = /[\uFDD0-\uFDD8]/g; export function hasGlob(segment, access) { return (GLOB_CHARS.some((ch) => segment.includes(ch)) || (extglobActive(access) && EXTGLOB_RE.test(segment))); } // Quote every glob character, the way enclosing quotes would. export function markGlobs(text) { return text.replace(GLOB_CHAR_RE, (ch) => GLOB_MARKS[ch] ?? ch); } // The literal spelling: every quoted glob character as itself. export function unmarkGlobs(text) { return text.replace(GLOB_MARK_RE, (ch) => GLOB_CHAR_OF[ch] ?? ch); } // Whether text still carries a glob character quoting made literal. function hasGlobMarks(text) { return Object.keys(GLOB_CHAR_OF).some((mark) => text.includes(mark)); } /** * Mark the glob characters a backslash quotes in raw word text. * * Read the way bash reads an unquoted word: `\*` is a quoted star and * `\\*` is a literal backslash followed by a live star. The backslash is * left in place for the quote-removal pass that follows, which drops it * and leaves the mark behind. */ export function markEscapedGlobs(text) { if (!text.includes('\\')) return text; let out = ''; let i = 0; while (i < text.length) { const ch = text[i] ?? ''; if (ch === '\\' && i + 1 < text.length) { const next = text[i + 1] ?? ''; out += ch + (GLOB_MARKS[next] ?? next); i += 2; continue; } out += ch; i += 1; } return out; } /** * A marked segment as the pattern fnmatch has to see. * * fnmatch has no escape character, so a quoted glob character is handed * over as its own one-character class, exactly what `escapeGlob` builds * for text that is literal throughout. Extended operators retain their * marks until matching: turning a quoted `@` into `[@]` inside `[a"@"]` * would create a nested bracket expression; unquoting it could create a group. */ export function globPattern(segment) { return segment.replace(GLOB_MARK_RE, (ch) => GLOB_CHARS.includes(GLOB_CHAR_OF[ch] ?? '') ? `[${GLOB_CHAR_OF[ch] ?? ch}]` : ch); } // Drop the marks from a spec, leaving the literal path it names. function unmarkSpec(spec) { return new PathSpec({ virtual: unmarkGlobs(spec.virtual), directory: unmarkGlobs(spec.directory), vfsPath: unmarkGlobs(spec.vfsPath), rawPath: unmarkGlobs(spec.rawPath), pattern: spec.pattern === null ? null : unmarkGlobs(spec.pattern), resolved: spec.resolved, }); } /** * The word after quote removal, once glob resolution is over. * * The marks come off here, and a word still carrying a pattern is frozen * as its literal: that pattern outlived its marks (an unmatched glob, * `set -f`, a backend that could not resolve it), and reading the * unmarked text as a pattern again would let a quoted metacharacter * match -- `rm '/data/*'?.txt` would be back to reaching every name the * live `?` alone would. A word that carried no marks is returned * untouched. */ export function literalWord(item) { if (typeof item === 'string') return unmarkGlobs(item); if (!hasGlobMarks(item.virtual) && !hasGlobMarks(item.pattern ?? '')) return item; const spec = unmarkSpec(item); if (spec.pattern === null) return spec; return new PathSpec({ virtual: spec.virtual, directory: spec.directory, vfsPath: spec.vfsPath, rawPath: spec.rawPath, pattern: null, resolved: true, }); } /** * Encode text so the glob matcher reads every character literally. * * fnmatch has no escape character, so each special is wrapped in its own * one-character class: `*` becomes `[*]`. A `]` needs no treatment: outside * a class it is already literal, and no class can open because every `[` * gets wrapped. Extended operators retain the quote marks in `globPattern`. */ export function escapeGlob(text) { return globPattern(markGlobs(text)); } // Whether a pattern spec is a typed word (not a directory listing). A // classify-shaped word puts the pattern inside `virtual` (`/data/s*/x.txt` // with directory `/data/s*/`); a dir-shaped spec (`PathSpec.dir`) sets // `virtual` to the directory itself. export function isWordShaped(p) { return rstripSlash(p.virtual) !== rstripSlash(p.directory); } /** Path components under the active shell's pattern grammar. */ export function globParts(pattern, access) { return extglobActive(access) ? patternParts(pattern) : pattern.split('/'); } // Spell a match the way bash expansion would. Bash rewrites only the glob // segments of the typed word; everything before the first glob segment keeps // its typed spelling, so `../s*/x.txt` expands to `../sub/x.txt`. The walked // tail has the same segment count in the typed word and in the match's // virtual path, so the spelling is the typed head plus the match's last // `walked` segments. export function spellMatch(raw, virtual, walked, access) { const head = globParts(rstripSlash(raw), access).slice(0, -walked); const tail = rstripSlash(virtual).split('/').slice(-walked); return [...head.map(unmarkGlobs), ...tail].join('/'); } // Whether a match is a directory, the way a trailing slash asks. One stat // per match, served from the index the readdir just filled. A link's own // row is followed through `targetStat`, which stats what the link reaches, // so a link to a directory is kept and a link to a file or to nothing is // dropped, bash's own rule for `*/`. Without a stat function every match is // kept, since nothing can tell them apart. async function isDirectory(stat, accessor, match, index, targetStat) { if (stat === undefined) return true; let row; try { row = await stat(accessor, match, index); } catch (err) { if (isFsError(err)) return false; throw err; } if (row.type === FileType.SYMLINK && targetStat !== undefined) { const target = await targetStat(rstripSlash(match.virtual)); return target?.type === FileType.DIRECTORY; } return row.type === FileType.DIRECTORY; } export async function resolveGlobWith(readdir, accessor, paths, index, limit = -1, stat, targetStat, access, follow, signal = callerSignal() ?? null) { const result = []; const vis = sessionVisibility(access); for (const p of paths) { if (p.resolved) { result.push(p); continue; } if (p.pattern !== null && p.pattern !== '') { // A trailing slash asks for directories only, and every match keeps // one (`*/` -> `sub/`), the same rule the shell tier applies in // workspace/expand/globs.ts. The slash is not part of the spelling // to rebuild, so it comes off the word here and goes back on each // match; the literal answer to a zero-match glob is still the word // as typed (#1065). const dirsOnly = p.rawPath.endsWith('/') && p.rawPath !== p.virtual; const word = dirsOnly ? new PathSpec({ virtual: p.virtual, directory: p.directory, vfsPath: p.vfsPath, pattern: p.pattern, resolved: p.resolved, rawPath: rstripSlash(p.rawPath), }) : p; // The hidden filter sits here, in the one loop every backend's // resolveGlob runs through, because per-backend glob modules bind // raw readdirs that never pass the command guards. It runs // before the empty-match test so an all-hidden match set reads as // no matches and falls back to the literal word, exactly what bash // prints when nothing matched. let matched = (await expandPattern(readdir, accessor, word, index, access, signal)).filter((m) => pathVisible(vis, m.virtual)); if (dirsOnly) { const kept = []; for (const m of matched) { if (await isDirectory(stat, accessor, m, index, targetStat)) { kept.push(new PathSpec({ virtual: m.virtual, directory: m.directory, vfsPath: m.vfsPath, pattern: m.pattern, resolved: m.resolved, rawPath: `${m.rawPath}/`, })); } } matched = kept; } // Dir-shaped specs keep the empty result, which is what a caller // that has to merge these matches with another source asks for. if (matched.length === 0 && isWordShaped(p)) { // The literal is the word after quote removal, so the marks come // off here. result.push(unmarkSpec(new PathSpec({ virtual: p.virtual, directory: p.directory, vfsPath: p.vfsPath, pattern: null, resolved: true, rawPath: p.rawPath, }))); continue; } if (limit >= 0 && matched.length > limit) { throw new GlobLimitError(unmarkGlobs(p.rawPath), limit); } // A typed word's matches are the words bash would have put on the // line, so a link one names resolves as the name typed would; an // internal expansion's matches are left as listed. result.push(...(follow !== undefined && isWordShaped(p) ? await followMatches(follow, matched) : matched)); } else { result.push(p); } } return result; } /** * Whether one directory entry answers a pathname-expansion segment. * `fnmatch` plus bash's leading-dot rule: a name starting with `.` is * matched only by a pattern that also starts with `.` (so `*`, `?h` and * `[.]h` pass over `.h`), unless the session has `shopt -s dotglob`. * Pathname expansion's rule alone; `find -name` and `case` match through * `fnmatch` directly. */ export function globNameMatches(name, pattern, access) { return fnmatch(name, pattern, extglobActive(access), !dotglobActive(access)); } /** * Expand a glob PathSpec segment-by-segment via readdir. * * Mirrors bash globbing: every path component containing a glob character is * matched against the entries of its (already expanded) parent directory, so * a mid-path pattern (a `Demo_*` directory segment followed by `page.md`) * never reaches the backend as a literal `*` path segment. An intermediate * match that cannot be listed (a file, or a vanished entry) is skipped, * matching bash's directories-only descent for non-final components. */ export async function expandPattern(readdir, accessor, path, index, access, signal = callerSignal() ?? null) { const prefix = path.virtual.slice(0, rstripSlash(path.virtual).length - path.vfsPath.length); const segments = path.vfsPath === '' ? [] : globParts(path.vfsPath, access); // Two spec shapes reach resolvers: a full pattern path (classify), where // the pattern is already the last segment, and a directory-shaped spec // (PathSpec.dir), where the pattern applies to the directory's entries. if (path.pattern !== null && path.pattern !== '' && segments.at(-1) !== path.pattern) { segments.push(path.pattern); } let first = segments.findIndex((seg) => hasGlob(seg, access)); if (first < 0) first = segments.length - 1; // The head above the first glob segment is a real directory, so a glob // character quoted inside it is part of the name to list. const base = unmarkGlobs(rstripSlash(prefix + segments.slice(0, first).join('/')) || '/'); let level = [base]; for (const seg of segments.slice(first)) { const nextLevel = []; const matcher = new Glob(globPattern(seg), extglobActive(access), !dotglobActive(access), 'shell', signal ?? undefined); try { for (const parent of level) { // Directory-shaped, carrying the segment as the pattern: a backend // whose listing for this level is a bounded window moves the window to // what the glob asks for instead of filtering its own (gcal, gdocs and // the dated-message channels). Every other readdir reads the directory // off the same spec and ignores the field. A literal segment carries // none, so it keeps its warm listing. const spec = new PathSpec({ virtual: parent, directory: parent, vfsPath: rekey(path.virtual, path.vfsPath, parent), pattern: hasGlob(seg, access) ? seg : null, }); // bash skips a directory it cannot open, so a pattern under one // matches nothing there and the word stays as typed. let entries; try { entries = await readdir(accessor, spec, index); } catch (err) { const code = err.code; if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'EACCES') throw err; logger.debug?.(`glob: ${parent} lists nothing: ${String(err)}`); entries = []; } // A cold listing marks a folder with a trailing slash (box, gdrive, // dropbox); the marker is not part of the name. for (const e of entries) { const entry = rstripSlash(e); if (await matcher.matchesAsync(entry.split('/').pop() ?? '')) nextLevel.push(entry); } } } finally { matcher.close(); } // bash sorts a pathname expansion. level = [...new Set(nextLevel)].sort(compareCodePoints); if (level.length === 0) return []; } const matches = level.map((e) => PathSpec.fromStrPath(e, rekey(path.virtual, path.vfsPath, e))); // A typed word (raw differs from virtual) spells its matches; the // dir-shaped specs internal expansions build (PathSpec.dir) have no typed // form and keep the resolved virtual. if (path.rawPath === path.virtual) return matches; const walked = segments.length - first; const raw = path.rawPath; return matches.map((m) => new PathSpec({ virtual: m.virtual, directory: m.directory, vfsPath: m.vfsPath, pattern: m.pattern, resolved: m.resolved, rawPath: spellMatch(raw, m.virtual, walked, access), })); } /** * A resolver of glob operands over `readdir`, path-first: `stat` is what lets * a trailing slash keep directories only, `targetStat` what a link it matched * points at, `follow` the line's operand resolution a typed word's matches * take, and `resolved` is told what each resolution answers, in order: the * operands a command's reads are warmed ahead of. Mirrors Python's * `make_resolve_glob`. */ export function makeResolveGlob(readdir, limit = -1, stat, targetStat, access, follow, resolved) { return async (paths) => { const found = await resolveGlobWith((_accessor, path) => readdir(path), null, paths, undefined, limit, stat === undefined ? undefined : (_accessor, path) => stat(path), targetStat, access, follow); resolved?.(found); return found; }; } // The line's operand resolution over a word's matches. A followed name keeps // its spelling but takes the target's key, which the walk leaves for the // mount to stamp. async function followMatches(follow, matched) { const followed = await follow(matched); return matched.flatMap((m, i) => { const f = followed[i]; if (!(f instanceof PathSpec)) return []; return f.virtual === m.virtual ? [f] : [PathSpec.fromStrPath(f, rekey(m.virtual, m.vfsPath, f.virtual))]; }); } //# sourceMappingURL=glob_walk.js.map