import micromatch from 'micromatch' export interface GrepMatch { path: string line: number content: string } export type GrepOutputMode = 'content' | 'files_with_matches' | 'count' export interface GrepOptions { maxResults?: number outputMode?: GrepOutputMode ignoreCase?: boolean lineNumbers?: boolean context?: number } export interface GrepCountEntry { path: string count: number } /** * Thrown when a single-file content grep (see `WorkspaceVFS.grepFile`) hits an * expected, user-facing condition: the path is not a single workspace file, the * file has no searchable text (image/binary), or it exceeds the inline read cap. * The grep handler surfaces the message verbatim instead of treating it as an * internal failure. Defined here (rather than in `workspace-vfs.ts`) so the * handler can reference it without pulling in the VFS module's heavy deps. */ export class WorkspaceFileGrepError extends Error { readonly code = 'WORKSPACE_FILE_GREP' as const constructor(message: string) { super(message) this.name = 'WorkspaceFileGrepError' } } /** * True when file content is one of `readFileRecord`'s non-text placeholders * (binary, unparseable, or over the inline read cap) — these carry no searchable * content, so grepping them should report the placeholder instead. */ function isNonGreppablePlaceholder(content: string, totalLines: number): boolean { if (totalLines !== 1) return false return /^\[(File too large|Image too large|Document too large|Could not parse|Binary file|Compiled artifact too large)/.test( content.trim() ) } /** * Run a single-file content grep over an already-resolved file read result, * shared by workspace-file grep (`WorkspaceVFS.grepFile`) and chat-upload grep. * Throws {@link WorkspaceFileGrepError} when the file has no searchable text * (image/binary attachment) or is a size/parse placeholder; otherwise greps the * text with the standard {@link grep} engine over a one-entry map keyed by * `path`. `readHint` is the path to suggest in the "use read(...)" message. */ export function grepReadResult( path: string, result: { content: string; totalLines: number; attachment?: unknown }, pattern: string, readHint: string, options?: GrepOptions ): GrepMatch[] | string[] | GrepCountEntry[] { if (result.attachment) { throw new WorkspaceFileGrepError( `Cannot grep "${path}" — it has no searchable text (image/binary). Use read("${readHint}") to view it.` ) } if (isNonGreppablePlaceholder(result.content, result.totalLines)) { throw new WorkspaceFileGrepError(result.content) } return grep(new Map([[path, result.content]]), pattern, undefined, options) } export interface ReadResult { content: string totalLines: number } /** * Micromatch options tuned to match the prior in-house glob: `bash: false` so a single `*` * never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace` * and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for * well-tested `**` and edge cases instead of a custom `RegExp`. */ const VFS_GLOB_OPTIONS: micromatch.Options = { bash: false, dot: false, windows: false, nobrace: true, noext: true, } /** * Splits VFS text into lines for line-oriented grep. Strips a trailing CR so Windows-style * CRLF payloads still match patterns anchored at line end (`$`). */ function splitLinesForGrep(content: string): string[] { return content.split('\n').map((line) => line.replace(/\r$/, '')) } /** * Returns true when `filePath` is `scope` or a descendant path (`scope/...`). If `scope` contains * `*` or `?`, filters with micromatch `isMatch` and {@link VFS_GLOB_OPTIONS}. Other characters * (including `[`, `{`, spaces) use directory-prefix logic so literal VFS path segments are not * parsed as glob syntax. Trailing slashes are stripped so `files/` and `files` both scope under * `files/...`. * * Exported so the lazy VFS can resolve exactly the lazy artifacts a scoped grep will consider, * keeping "what we materialize" identical to "what grep filters in". */ export function pathWithinGrepScope(filePath: string, scope: string): boolean { const scopeUsesStarOrQuestionGlob = /[*?]/.test(scope) if (scopeUsesStarOrQuestionGlob) { return micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS) } const base = scope.replace(/\/+$/, '') if (base === '') { return true } return filePath === base || filePath.startsWith(`${base}/`) } /** * Regex search over VFS file contents using ECMAScript `RegExp` syntax. * `content` and `count` are line-oriented (split on newline, CR stripped per line). * `files_with_matches` tests the entire file string once, so multiline patterns can match there * but not in line modes. */ export function grep( files: Map, pattern: string, path?: string, opts?: GrepOptions ): GrepMatch[] | string[] | GrepCountEntry[] { const maxResults = opts?.maxResults ?? 100 const outputMode = opts?.outputMode ?? 'content' const ignoreCase = opts?.ignoreCase ?? false const showLineNumbers = opts?.lineNumbers ?? true const contextLines = opts?.context ?? 0 const flags = ignoreCase ? 'gi' : 'g' let regex: RegExp try { regex = new RegExp(pattern, flags) } catch { return [] } if (outputMode === 'files_with_matches') { const matchingFiles: string[] = [] for (const [filePath, content] of files) { if (path && !pathWithinGrepScope(filePath, path)) continue regex.lastIndex = 0 if (regex.test(content)) { matchingFiles.push(filePath) if (matchingFiles.length >= maxResults) break } } return matchingFiles } if (outputMode === 'count') { const counts: GrepCountEntry[] = [] for (const [filePath, content] of files) { if (path && !pathWithinGrepScope(filePath, path)) continue const lines = splitLinesForGrep(content) let count = 0 for (const line of lines) { regex.lastIndex = 0 if (regex.test(line)) count++ } if (count > 0) { counts.push({ path: filePath, count }) if (counts.length >= maxResults) break } } return counts } // Default: 'content' mode const matches: GrepMatch[] = [] for (const [filePath, content] of files) { if (path && !pathWithinGrepScope(filePath, path)) continue const lines = splitLinesForGrep(content) for (let i = 0; i < lines.length; i++) { regex.lastIndex = 0 if (regex.test(lines[i])) { if (contextLines > 0) { const start = Math.max(0, i - contextLines) const end = Math.min(lines.length - 1, i + contextLines) for (let j = start; j <= end; j++) { matches.push({ path: filePath, line: showLineNumbers ? j + 1 : 0, content: lines[j], }) } } else { matches.push({ path: filePath, line: showLineNumbers ? i + 1 : 0, content: lines[i], }) } if (matches.length >= maxResults) return matches } } } return matches } /** * Glob pattern matching against VFS file paths and virtual directories using `micromatch` * with {@link VFS_GLOB_OPTIONS} (path-aware `*` and `?`, `**`, no brace or extglob expansion). * Returns matching file keys and virtual directory prefixes. */ export function glob(files: Map, pattern: string): string[] { const result = new Set() const directories = new Set() for (const filePath of files.keys()) { if (filePath.endsWith('/.folder')) { directories.add(filePath.slice(0, -'/.folder'.length)) continue } const parts = filePath.split('/') for (let i = 1; i < parts.length; i++) { directories.add(parts.slice(0, i).join('/')) } } for (const filePath of files.keys()) { if (filePath.endsWith('/.folder')) continue if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) { result.add(filePath) } } for (const dir of directories) { if (micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS)) { result.add(dir) } } return Array.from(result).sort() } /** * Read a VFS file's content, optionally with offset and limit. * Returns null if the file does not exist. */ export function read( files: Map, path: string, offset?: number, limit?: number ): ReadResult | null { let content = files.get(path) // Fallback: normalize Unicode and retry for encoding mismatches if (content === undefined) { const normalized = path.normalize('NFC') content = files.get(normalized) if (content === undefined) { for (const [key, value] of files) { if (key.normalize('NFC') === normalized) { content = value break } } } } if (content === undefined) return null const lines = content.split('\n') const totalLines = lines.length if (offset !== undefined || limit !== undefined) { const rawStart = Number.isFinite(offset) ? (offset as number) : 0 const start = Math.max(0, Math.min(totalLines, rawStart)) const rawEnd = limit !== undefined ? start + Math.max(0, limit) : totalLines const end = Math.max(start, Math.min(totalLines, rawEnd)) return { content: lines.slice(start, end).join('\n'), totalLines, } } return { content, totalLines } } /** * Find VFS paths similar to a missing path. * * Handles two cases: * 1. Wrong filename: `components/blocks/gmail.json` → `gmail_v2.json` * Matches by filename stem similarity within the same directory. * 2. Wrong directory: `workflows/Untitled/state.json` → `Untitled Workflow` * Matches by parent directory name similarity with the same filename. */ export function suggestSimilar(files: Map, missingPath: string, max = 5): string[] { const segments = missingPath.split('/') const filename = segments[segments.length - 1].toLowerCase() const fileStem = filename.replace(/\.[^.]+$/, '') const parentDir = segments.length >= 2 ? segments[segments.length - 2].toLowerCase() : '' const topDir = segments.length >= 1 ? `${segments[0]}/` : '' const scored: Array<{ path: string; score: number }> = [] for (const vfsPath of files.keys()) { const vfsSegments = vfsPath.split('/') const vfsFilename = vfsSegments[vfsSegments.length - 1].toLowerCase() const vfsStem = vfsFilename.replace(/\.[^.]+$/, '') const vfsParentDir = vfsSegments.length >= 2 ? vfsSegments[vfsSegments.length - 2].toLowerCase() : '' const sameTopDir = topDir && vfsPath.startsWith(topDir) // Same filename, different directory — the directory name is wrong. // e.g. workflows/Untitled/state.json vs workflows/Untitled Workflow/state.json if (vfsFilename === filename && vfsParentDir !== parentDir && sameTopDir) { if (vfsParentDir.includes(parentDir) || parentDir.includes(vfsParentDir)) { scored.push({ path: vfsPath, score: 95 }) continue } } // Same directory, different filename — the filename is wrong. const sameDir = segments.length === vfsSegments.length && segments.slice(0, -1).join('/') === vfsSegments.slice(0, -1).join('/') if (sameDir) { if (vfsStem === fileStem) { scored.push({ path: vfsPath, score: 100 }) } else if (vfsStem.includes(fileStem) || fileStem.includes(vfsStem)) { scored.push({ path: vfsPath, score: 80 }) } else if (vfsFilename.includes(fileStem.replace(/[_-]/g, ''))) { scored.push({ path: vfsPath, score: 60 }) } } else if (sameTopDir && vfsStem === fileStem) { // Same top-level directory and matching stem but different depth/parent scored.push({ path: vfsPath, score: 50 }) } } scored.sort((a, b) => b.score - a.score) return scored.slice(0, max).map((s) => s.path) }