{"version":3,"file":"native-search.d.ts","sourceRoot":"","sources":["../../../src/core/tools/native-search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAQH,KAAK,SAAS,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;AAUjC,MAAM,WAAW,cAAc;IAC9B,qBAAqB;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,SAAS,CAAC;CAChB;AAiCD,MAAM,WAAW,WAAW;IAC3B,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,iEAAiE;IACjE,cAAc,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,GAAG,cAAc,EAAE,CA8DrF;AAED,UAAU,eAAe;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,WAAW,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;CAC7D;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,iBAAiB,EAAE,OAAO,CAAC;CAC3B;AAeD;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA+DjG;AAED,iFAAiF;AACjF,wBAAgB,oBAAoB,IAAI,OAAO,CAE9C","sourcesContent":["/**\n * Native (pure-JS) fallbacks for the `find` and `grep` tools.\n *\n * The tools normally shell out to `fd` / `rg`, which are downloaded on demand\n * (see tools-manager.ts). In restricted environments those binaries may be\n * neither on PATH nor downloadable, and `HOOCODE_NATIVE_SEARCH=1` can also force\n * this path. Rather than failing the tool and asking the model to fall back to\n * `bash`, these functions reproduce the essential behaviour in JS so search\n * degrades automatically:\n *\n *   - hierarchical `.gitignore` handling (each `.gitignore` is scoped to its own\n *     subtree, matching fd's `--no-require-git` behaviour and issue #3303),\n *   - hidden files included (like `fd --hidden` / `rg --hidden`),\n *   - `.git` always skipped; `node_modules` skipped for `find` (mirrors the\n *     tool's built-in excludes) but left to `.gitignore` for `grep` (like rg).\n *\n * These are best-effort approximations, not byte-for-byte fd/rg parity: globs\n * are matched with `minimatch` and patterns with JS `RegExp`, and only\n * `.gitignore` files are honoured (not `.ignore` or global excludes).\n */\n\nimport { readdirSync, readFileSync, statSync } from \"fs\";\nimport ignore, { type Ignore } from \"ignore\";\nimport { minimatch } from \"minimatch\";\nimport path from \"path\";\nimport { toPosixPath } from \"./fd-utils.js\";\n\ntype EntryType = \"f\" | \"d\" | \"l\";\n\n/** Hard cap on entries enumerated during a single walk, so a pathological tree\n *  can never hang the fallback. Well above any tool's own result limit. */\nconst MAX_ENTRIES = 200_000;\n\n/** Files larger than this are skipped by the grep fallback (rg streams; we read\n *  whole files, so we guard against loading huge blobs into memory). */\nconst MAX_GREP_FILE_BYTES = 20 * 1024 * 1024;\n\nexport interface CollectedEntry {\n\t/** Absolute path. */\n\tabs: string;\n\t/** POSIX path relative to the walk root. */\n\trel: string;\n\ttype: EntryType;\n}\n\n/** A `.gitignore` matcher scoped to the subtree rooted at `baseDir`. */\ninterface GitignoreMatcher {\n\tbaseDir: string;\n\tig: Ignore;\n}\n\nfunction loadGitignore(dir: string): Ignore | undefined {\n\tlet content: string;\n\ttry {\n\t\tcontent = readFileSync(path.join(dir, \".gitignore\"), \"utf-8\");\n\t} catch {\n\t\treturn undefined;\n\t}\n\treturn ignore().add(content);\n}\n\n/**\n * Whether `absPath` is ignored by any applicable `.gitignore`. Each matcher only\n * applies to paths inside its `baseDir`, and the path is tested relative to that\n * base — so `a/.gitignore` scopes to `a/` and its descendants but never `b/`.\n */\nfunction isGitIgnored(absPath: string, isDir: boolean, matchers: GitignoreMatcher[]): boolean {\n\tfor (const m of matchers) {\n\t\tconst rel = path.relative(m.baseDir, absPath);\n\t\tif (rel === \"\" || rel.startsWith(\"..\") || path.isAbsolute(rel)) continue;\n\t\tconst relPosix = toPosixPath(rel) + (isDir ? \"/\" : \"\");\n\t\tif (m.ig.ignores(relPosix)) return true;\n\t}\n\treturn false;\n}\n\nexport interface WalkOptions {\n\t/** Max entry depth relative to root; direct children are depth 1. */\n\tmaxDepth?: number;\n\tsignal?: AbortSignal;\n\t/** Directory names to never descend into. Defaults to `.git`. */\n\talwaysSkipDirs?: Set<string>;\n}\n\n/**\n * Walk `root` depth-first, returning every entry not excluded by `.gitignore`\n * or an always-skip directory. Symlinks are reported but never followed (avoids\n * cycles). Enumeration stops at {@link MAX_ENTRIES}.\n */\nexport function collectEntries(root: string, opts: WalkOptions = {}): CollectedEntry[] {\n\tconst out: CollectedEntry[] = [];\n\tconst alwaysSkip = opts.alwaysSkipDirs ?? new Set([\".git\"]);\n\n\tconst rootMatchers: GitignoreMatcher[] = [];\n\tconst rootIg = loadGitignore(root);\n\tif (rootIg) rootMatchers.push({ baseDir: root, ig: rootIg });\n\n\t// `depth` is the depth of `dir`; its direct children are at depth + 1.\n\tconst stack: Array<{ dir: string; depth: number; matchers: GitignoreMatcher[] }> = [\n\t\t{ dir: root, depth: 0, matchers: rootMatchers },\n\t];\n\n\twhile (stack.length > 0) {\n\t\tif (out.length >= MAX_ENTRIES || opts.signal?.aborted) break;\n\t\tconst { dir, depth, matchers } = stack.pop()!;\n\n\t\tlet dirents: import(\"fs\").Dirent[];\n\t\ttry {\n\t\t\tdirents = readdirSync(dir, { withFileTypes: true });\n\t\t} catch {\n\t\t\tcontinue; // unreadable directory — skip rather than abort the whole walk\n\t\t}\n\n\t\tconst entryDepth = depth + 1;\n\t\tfor (const dirent of dirents) {\n\t\t\tif (out.length >= MAX_ENTRIES) break;\n\n\t\t\tconst name = dirent.name;\n\t\t\tconst abs = path.join(dir, name);\n\n\t\t\tlet type: EntryType;\n\t\t\tlet isDir = false;\n\t\t\tif (dirent.isSymbolicLink()) {\n\t\t\t\ttype = \"l\";\n\t\t\t} else if (dirent.isDirectory()) {\n\t\t\t\ttype = \"d\";\n\t\t\t\tisDir = true;\n\t\t\t} else if (dirent.isFile()) {\n\t\t\t\ttype = \"f\";\n\t\t\t} else {\n\t\t\t\tcontinue; // sockets, fifos, block devices, …\n\t\t\t}\n\n\t\t\tif (isDir && alwaysSkip.has(name)) continue;\n\t\t\tif (isGitIgnored(abs, isDir, matchers)) continue;\n\n\t\t\tif (opts.maxDepth === undefined || entryDepth <= opts.maxDepth) {\n\t\t\t\tout.push({ abs, rel: toPosixPath(path.relative(root, abs)), type });\n\t\t\t}\n\n\t\t\t// Descend only into real directories, and only if their children can\n\t\t\t// still be within the depth budget.\n\t\t\tif (isDir && (opts.maxDepth === undefined || entryDepth < opts.maxDepth)) {\n\t\t\t\tconst childIg = loadGitignore(abs);\n\t\t\t\tconst nextMatchers = childIg ? [...matchers, { baseDir: abs, ig: childIg }] : matchers;\n\t\t\t\tstack.push({ dir: abs, depth: entryDepth, matchers: nextMatchers });\n\t\t\t}\n\t\t}\n\t}\n\n\treturn out;\n}\n\ninterface NativeGrepMatch {\n\tfilePath: string;\n\tlineNumber: number;\n\tlineText: string;\n}\n\nexport interface NativeGrepOptions {\n\tpattern: string;\n\t/** True when `root` is a directory; false when it is a single file. */\n\tisDirectory: boolean;\n\tignoreCase?: boolean;\n\tliteral?: boolean;\n\t/** Optional glob filter applied to file paths (like rg --glob). */\n\tglob?: string;\n\t/** Stop after this many matches. */\n\tlimit: number;\n\tsignal?: AbortSignal;\n\treadFile: (absolutePath: string) => Promise<string> | string;\n}\n\nexport interface NativeGrepResult {\n\tmatches: NativeGrepMatch[];\n\tmatchLimitReached: boolean;\n}\n\nfunction escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/** Cheap binary-file heuristic: a NUL byte in the first chunk. rg skips these. */\nfunction looksBinary(content: string): boolean {\n\tconst sampleLength = Math.min(content.length, 8192);\n\tfor (let i = 0; i < sampleLength; i++) {\n\t\tif (content.charCodeAt(i) === 0) return true;\n\t}\n\treturn false;\n}\n\n/**\n * Native replacement for the rg-backed content search. Collects up to `limit`\n * matches as `{ filePath, lineNumber, lineText }`, the same shape the tool's\n * formatter already consumes from rg's JSON output.\n *\n * Throws an Error tagged `invalidRegex` when a non-literal pattern is not a\n * valid JS regex, so the caller can surface the same \"pass literal: true\" hint.\n */\nexport async function nativeGrep(root: string, opts: NativeGrepOptions): Promise<NativeGrepResult> {\n\tlet regex: RegExp;\n\tconst flags = opts.ignoreCase ? \"i\" : \"\";\n\tif (opts.literal) {\n\t\tregex = new RegExp(escapeRegExp(opts.pattern), flags);\n\t} else {\n\t\ttry {\n\t\t\tregex = new RegExp(opts.pattern, flags);\n\t\t} catch (e) {\n\t\t\tconst error = new Error(e instanceof Error ? e.message : String(e)) as Error & { invalidRegex?: boolean };\n\t\t\terror.invalidRegex = true;\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tlet files: string[];\n\tif (!opts.isDirectory) {\n\t\tfiles = [root];\n\t} else {\n\t\tconst entries = collectEntries(root, { signal: opts.signal });\n\t\tfiles = entries\n\t\t\t.filter((e) => e.type === \"f\")\n\t\t\t.filter((e) => {\n\t\t\t\tif (!opts.glob) return true;\n\t\t\t\treturn minimatch(e.rel, opts.glob, { dot: true, matchBase: !opts.glob.includes(\"/\") });\n\t\t\t})\n\t\t\t.map((e) => e.abs);\n\t}\n\n\tconst matches: NativeGrepMatch[] = [];\n\tlet matchLimitReached = false;\n\n\tfor (const filePath of files) {\n\t\tif (opts.signal?.aborted || matches.length >= opts.limit) break;\n\n\t\ttry {\n\t\t\tif (statSync(filePath).size > MAX_GREP_FILE_BYTES) continue;\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet content: string;\n\t\ttry {\n\t\t\tcontent = await opts.readFile(filePath);\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tif (looksBinary(content)) continue;\n\n\t\tconst lines = content.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\");\n\t\tfor (let i = 0; i < lines.length; i++) {\n\t\t\t// New RegExp per file with no /g flag → test() is stateless across lines.\n\t\t\tif (regex.test(lines[i])) {\n\t\t\t\tmatches.push({ filePath, lineNumber: i + 1, lineText: lines[i] });\n\t\t\t\tif (matches.length >= opts.limit) {\n\t\t\t\t\tmatchLimitReached = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { matches, matchLimitReached };\n}\n\n/** Whether the native search path is forced regardless of fd/rg availability. */\nexport function isNativeSearchForced(): boolean {\n\treturn process.env.HOOCODE_NATIVE_SEARCH === \"1\";\n}\n"]}