From 917d3d455c5cedaa8af538792aac44644c4214b7 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 23 Sep 2026 07:23:03 +0200 Subject: [PATCH] find files --- README.md | 5 +- src/dir-listing.js | 43 ++++++++++++ src/fido.js | 1 + src/glob.js | 68 +++++++++++++++++++ src/tools/find-files.js | 146 ++++++++++++++++++++++++++++++++++++++++ src/tools/index.js | 5 ++ src/tools/ripgrep.js | 56 +-------------- test/glob.test.js | 61 +++++++++++++++++ 8 files changed, 328 insertions(+), 57 deletions(-) create mode 100644 src/glob.js create mode 100644 src/tools/find-files.js create mode 100644 test/glob.test.js diff --git a/README.md b/README.md index 4406ba3..6bd43be 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ **FIDO** a minimal coding assistant. -Fido is a console REPL assistant that inspects files, lists directories, searches file -contents, applies changes, and runs shell commands (with user approval). +Fido is a console REPL assistant that inspects files, lists directories, finds files +and directories by name, searches file contents, applies changes, and runs shell +commands (with user approval). ## Install diff --git a/src/dir-listing.js b/src/dir-listing.js index 33891ef..75b006a 100644 --- a/src/dir-listing.js +++ b/src/dir-listing.js @@ -117,3 +117,46 @@ export function walkFiles(dir) { walk(dir); return results; } + +/** + * Recursively walk the entries under `dir` (both files and directories), + * honoring .gitignore rules (including nested .gitignore files) plus the + * hardcoded node_modules rule. Dot-directories (names starting with '.', e.g. + * .git) are skipped unless `includeDotDirs` is true. Symlinks are skipped to + * avoid loops. + * + * Returns an array of { abs, rel, type, size, name } where `rel` is relative to + * ROOT_DIR using forward slashes: files have type 'f' and a byte `size`, + * directories have type 'd', size null, and a trailing "/" on `rel` (matching + * the spelling used by listDir). `name` is the entry's basename. + */ +export function walkTree(dir, { includeDotDirs = false } = {}) { + const results = []; + const walk = (current) => { + // Rebuild per directory so nested .gitignore files apply to their subtree. + const ig = buildIgnoreFilter(current); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + // Skip dot-directories (e.g. .git) by default. + if (e.isDirectory() && e.name.startsWith('.') && !includeDotDirs) continue; + const full = path.join(current, e.name); + const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/'); + if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue; + if (e.isDirectory()) { + results.push({ abs: full, rel: rel + '/', type: 'd', size: null, name: e.name }); + walk(full); + } else if (e.isFile()) { + let size = null; + try { size = fs.statSync(full).size; } catch { /* skip */ } + results.push({ abs: full, rel, type: 'f', size, name: e.name }); + } + } + }; + walk(dir); + return results; +} diff --git a/src/fido.js b/src/fido.js index a72f754..0e0ff02 100644 --- a/src/fido.js +++ b/src/fido.js @@ -110,6 +110,7 @@ ${dirSection} - \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing. - \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: list dir (non-recursive default). Honors .gitignore; dot-dirs (.git) hidden unless includeDotDirs=true. recursive=true walks subtree. seek/limit paginate. Paths rel to root. - \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: regex-search file contents across ws (literal=true = plain str). Recurse dirs, honors .gitignore, skips binary/big files, hides dot-dirs. Returns matches {file,line,column,text}; line is 0-idx, feed straight to read_file seek. glob limits files searched. Long lines capped (textTruncated per match, linesTruncated count). +- \`find_files(pattern, path, type, includeDotDirs, ignore_case, max_results)\`: find files/dirs by name glob across ws (like ripgrep, but for names). Recurses dirs, honors .gitignore, hides dot-dirs. A glob without "/" matches the basename (e.g. "*.js"); with "/" it matches the path. Returns entries {path,type,size}: type "f"/"d", size in bytes (null for dirs). type restricts to "f"/"d"; max_results caps the list. - \`patch(operations)\`: apply file edits/creates. - create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created). - search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` — replaces FIRST hit. diff --git a/src/glob.js b/src/glob.js new file mode 100644 index 0000000..780bab6 --- /dev/null +++ b/src/glob.js @@ -0,0 +1,68 @@ +'use strict'; + +/** + * Minimal glob support shared by the file-finding tools. + * + * Both `ripgrep` (content search filtered by `glob`) and `find_files` (name + * search) need to turn a small glob dialect into an anchored RegExp. Keeping a + * single implementation here avoids the two tools drifting apart. + */ + +/** Escape a string so it can be matched literally inside a RegExp. */ +export function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Translate a simple glob into a RegExp anchored at both ends. + * ** -> any characters (including "/") + * * -> any characters except "/" + * ? -> a single character except "/" + * \\ -> escapes the next character + * All other characters are matched literally (no brace/char-class expansion). + * When `ignoreCase` is true the pattern is compiled case-insensitively. + */ +export function globToRegExp(pattern, { ignoreCase = false } = {}) { + let re = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + re += '.*'; + i++; + // Let "**/" also match zero directories. + if (pattern[i + 1] === '/') i++; + } else { + re += '[^/]*'; + } + } else if (c === '?') { + re += '[^/]'; + } else if ('\\^$.|+()[]{}'.includes(c)) { + re += '\\' + c; + } else { + re += c; + } + } + return new RegExp('^' + re + '$', ignoreCase ? 'i' : ''); +} + +/** + * Build a predicate testing a workspace-relative path (forward slashes) against + * a glob. A glob without "/" is also matched against the basename, mirroring + * ripgrep's `-g '*.ext'` behaviour. A trailing "/" (how directories are spelled + * in listings) is ignored while matching, and callers may pass the basename + * explicitly via the second argument. + */ +export function makeGlobMatcher(pattern, { ignoreCase = false } = {}) { + const rx = globToRegExp(pattern, { ignoreCase }); + const basenameOnly = !pattern.includes('/'); + return (rel, base) => { + const trimmed = rel.endsWith('/') ? rel.slice(0, -1) : rel; + if (rx.test(trimmed)) return true; + if (basenameOnly) { + const name = base ?? trimmed.slice(trimmed.lastIndexOf('/') + 1); + return rx.test(name); + } + return false; + }; +} diff --git a/src/tools/find-files.js b/src/tools/find-files.js new file mode 100644 index 0000000..9c3f752 --- /dev/null +++ b/src/tools/find-files.js @@ -0,0 +1,146 @@ +'use strict'; + +/** + * find_files — recursive, .gitignore-aware search for files and directories by + * name (the name-space sibling of ripgrep, which searches file contents). + * + * Directory traversal lives in src/dir-listing.js (walkTree); glob matching + * lives in src/glob.js (makeGlobMatcher). Result entries mirror list_files: + * { path, type, size } with type 'f'/'d' and size in bytes (null for dirs). + */ + +import fs from 'fs'; +import path from 'path'; +import { defineTool, ToolError } from 'llmorbiter'; +import { resolveSafe } from '../sandbox.js'; +import { ROOT_DIR } from '../config.js'; +import { walkTree } from '../dir-listing.js'; +import { makeGlobMatcher } from '../glob.js'; +import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; + +export const findFilesTool = defineTool({ + name: 'find_files', + description: + 'Find files and/or directories by name glob across ws (like ripgrep, but for names). ' + + 'Recurses dirs, honors .gitignore, hides dot-dirs (.git) by default. ' + + 'A glob without "/" matches the basename (e.g. "*.js"); with "/" it matches the path. ' + + 'Returns entries {path,type,size}: type is "f"/"d", size in bytes (null for dirs). ' + + 'type restricts to files ("f") or dirs ("d"); max_results caps the list.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['pattern'], + properties: { + pattern: { + type: 'string', + description: 'Name glob, e.g. "*.js", "test*" or "src/**/*.ts".', + }, + path: { + type: 'string', + description: 'Dir to search under, rel to root. Default "."', + }, + type: { + type: 'string', + enum: ['f', 'd'], + description: 'Restrict to files ("f") or dirs ("d"). Default both.', + }, + includeDotDirs: { + type: 'boolean', + description: 'true = descend into dot-dirs (.git). Default false.', + }, + ignore_case: { + type: 'boolean', + description: 'Case-insensitive glob match. Default false.', + }, + max_results: { + type: 'number', + description: 'Max entries. Default 100.', + }, + }, + }, + invoke: async ({ + pattern, + path: searchPath = '.', + type, + includeDotDirs = false, + ignore_case = false, + max_results = 100, + }) => { + if (typeof pattern !== 'string' || pattern.length === 0) { + throw new ToolError('"pattern" must be a non-empty string.'); + } + if (type !== undefined && type !== 'f' && type !== 'd') { + throw new ToolError(`"type" must be "f" or "d" (received ${JSON.stringify(type)}).`); + } + if (!Number.isInteger(max_results) || max_results < 1) { + throw new ToolError(`"max_results" must be a positive integer (received ${max_results}).`); + } + + const absTarget = resolveSafe(searchPath); + if (!fs.existsSync(absTarget)) { + throw new ToolError(`Path not found: "${searchPath}"`); + } + + // A file target is matched directly; a directory is walked recursively. + const targetStat = fs.statSync(absTarget); + let candidates; + if (targetStat.isDirectory()) { + candidates = walkTree(absTarget, { includeDotDirs }); + } else { + const rel = path.relative(ROOT_DIR, absTarget).replace(/\\/g, '/'); + candidates = [{ abs: absTarget, rel, type: 'f', size: targetStat.size, name: path.basename(absTarget) }]; + } + + const matchName = makeGlobMatcher(pattern, { ignoreCase: ignore_case }); + const matched = candidates.filter( + (e) => (!type || e.type === type) && matchName(e.rel, e.name), + ); + + const total = matched.length; + const shown = matched.slice(0, max_results); + const entries = shown.map((e) => ({ path: e.rel, type: e.type, size: e.size })); + + const result = { + pattern, + path: searchPath, + type: type ?? null, + ignoreCase: ignore_case, + entries, + returned: entries.length, + total, + }; + const truncated = total - entries.length; + if (truncated > 0) { + result.truncated = truncated; + result.truncationNote = + `${truncated} more entr${truncated === 1 ? 'y' : 'ies'} matched but were not shown. ` + + `Refine the pattern or raise max_results to see more.`; + } + return result; + }, +}); + +/** + * Call notification: the name glob plus scope (path) and filters in effect. + */ +export function notifyCall(args = {}) { + const { pattern, path: searchPath, type, includeDotDirs = false, ignore_case = false } = args; + const parts = [JSON.stringify(pattern)]; + if (searchPath && searchPath !== '.') parts.push(`path=${JSON.stringify(searchPath)}`); + if (type) parts.push(`type=${type}`); + if (includeDotDirs) parts.push('includeDotDirs'); + if (ignore_case) parts.push('ignore_case'); + emitToolCall(findFilesTool.name, clip(parts.join(' '))); +} + +/** + * Result notification: how many entries came back and the payload size. + */ +export function notifyResult(result = {}) { + const returned = result.returned ?? (Array.isArray(result.entries) ? result.entries.length : 0); + const total = result.total ?? returned; + const count = total > returned + ? `${returned} of ${total} entries` + : plural(returned, 'entry', 'entries'); + emitToolResult(findFilesTool.name, `${count} ${formatBytes(resultSize(result))}`); +} diff --git a/src/tools/index.js b/src/tools/index.js index 0f62eb9..2151c46 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -9,6 +9,7 @@ */ import { listFilesTool, notifyCall as listFilesNotify, notifyResult as listFilesResult } from './list-files.js'; +import { findFilesTool, notifyCall as findFilesNotify, notifyResult as findFilesResult } from './find-files.js'; import { readFileTool, notifyCall as readFileNotify, notifyResult as readFileResult } from './read-file.js'; import { ripgrepTool, notifyCall as ripgrepNotify, notifyResult as ripgrepResult } from './ripgrep.js'; import { patchTool, notifyCall as patchNotify } from './patch.js'; @@ -23,6 +24,7 @@ import { askExpertTool, notifyCall as askExpertNotify } from './ask-expert.js'; export { listFilesTool, + findFilesTool, readFileTool, ripgrepTool, patchTool, @@ -38,6 +40,7 @@ export { export const tools = [ listFilesTool, + findFilesTool, readFileTool, ripgrepTool, patchTool, @@ -59,6 +62,7 @@ export const tools = [ export const toolCallNotifications = Object.fromEntries( [ [listFilesTool, listFilesNotify], + [findFilesTool, findFilesNotify], [readFileTool, readFileNotify], [ripgrepTool, ripgrepNotify], [patchTool, patchNotify], @@ -82,6 +86,7 @@ export const toolCallNotifications = Object.fromEntries( export const toolResultNotifications = Object.fromEntries( [ [listFilesTool, listFilesResult], + [findFilesTool, findFilesResult], [readFileTool, readFileResult], [ripgrepTool, ripgrepResult], [bashExecTool, bashExecResult], diff --git a/src/tools/ripgrep.js b/src/tools/ripgrep.js index 5ba5aa6..71c6f41 100644 --- a/src/tools/ripgrep.js +++ b/src/tools/ripgrep.js @@ -13,6 +13,7 @@ import { defineTool, ToolError } from 'llmorbiter'; import { resolveSafe } from '../sandbox.js'; import { ROOT_DIR } from '../config.js'; import { walkFiles } from '../dir-listing.js'; +import { escapeRegExp, makeGlobMatcher } from '../glob.js'; import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; // Files larger than this are skipped during a search to keep things responsive. @@ -24,61 +25,6 @@ const MAX_MATCH_TEXT_LEN = 300; // ─── Helpers ───────────────────────────────────────────────────────────────── -/** Escape a string so it can be matched literally inside a RegExp. */ -function escapeRegExp(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Translate a simple glob into a RegExp anchored at both ends. - * ** -> any characters (including "/") - * * -> any characters except "/" - * ? -> a single character except "/" - * \\ -> escapes the next character - * All other characters are matched literally (no brace/char-class expansion). - */ -function globToRegExp(pattern) { - let re = ''; - for (let i = 0; i < pattern.length; i++) { - const c = pattern[i]; - if (c === '*') { - if (pattern[i + 1] === '*') { - re += '.*'; - i++; - // Let "**/" also match zero directories. - if (pattern[i + 1] === '/') i++; - } else { - re += '[^/]*'; - } - } else if (c === '?') { - re += '[^/]'; - } else if ('\\^$.|+()[]{}'.includes(c)) { - re += '\\' + c; - } else { - re += c; - } - } - return new RegExp('^' + re + '$'); -} - -/** - * Build a predicate testing a workspace-relative path (forward slashes) against - * a glob. A glob without "/" is also matched against the basename, mirroring - * ripgrep's `-g '*.ext'` behaviour. - */ -function makeGlobMatcher(pattern) { - const rx = globToRegExp(pattern); - const basenameOnly = !pattern.includes('/'); - return (rel) => { - if (rx.test(rel)) return true; - if (basenameOnly) { - const base = rel.slice(rel.lastIndexOf('/') + 1); - return rx.test(base); - } - return false; - }; -} - /** * True when a workspace-relative path (forward slashes) lives inside a * dot-directory — i.e. any *directory* segment starts with ".". Traversal into diff --git a/test/glob.test.js b/test/glob.test.js new file mode 100644 index 0000000..f85bd5b --- /dev/null +++ b/test/glob.test.js @@ -0,0 +1,61 @@ +'use strict'; + +/** + * Tests for the glob helpers in src/glob.js, shared by ripgrep and find_files. + * + * Run with: node --test + * (or) node --test test/glob.test.js + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { escapeRegExp, globToRegExp, makeGlobMatcher } from '../src/glob.js'; + +test('escapeRegExp: escapes regex metacharacters', () => { + assert.equal(escapeRegExp('a.b*c'), 'a\\.b\\*c'); + assert.ok(new RegExp(escapeRegExp('a.b')).test('a.b')); + assert.ok(!new RegExp(escapeRegExp('a.b')).test('axb')); +}); + +test('globToRegExp: * stays within a path segment', () => { + const rx = globToRegExp('*.js'); + assert.ok(rx.test('a.js')); + assert.ok(!rx.test('src/a.js')); +}); + +test('globToRegExp: ** crosses path separators', () => { + const rx = globToRegExp('src/**/*.js'); + assert.ok(rx.test('src/a/b/c.js')); + // "**/" also matches zero directories. + assert.ok(rx.test('src/a.js')); +}); + +test('globToRegExp: ? matches a single non-slash char', () => { + const rx = globToRegExp('a?c'); + assert.ok(rx.test('abc')); + assert.ok(!rx.test('ac')); + assert.ok(!rx.test('a/c')); +}); + +test('makeGlobMatcher: slashless glob also matches the basename', () => { + const m = makeGlobMatcher('*.js'); + assert.ok(m('src/a.js', 'a.js')); + assert.ok(!m('src/a.ts', 'a.ts')); +}); + +test('makeGlobMatcher: glob with a slash matches the whole path', () => { + const m = makeGlobMatcher('src/**/*.js'); + assert.ok(m('src/tools/a.js', 'a.js')); + assert.ok(!m('test/a.js', 'a.js')); +}); + +test('makeGlobMatcher: ignores a trailing slash on directories', () => { + const m = makeGlobMatcher('node_modules'); + assert.ok(m('node_modules/', 'node_modules')); +}); + +test('makeGlobMatcher: ignoreCase makes matching case-insensitive', () => { + assert.ok(makeGlobMatcher('*.JS', { ignoreCase: true })('a.js', 'a.js')); + assert.ok(!makeGlobMatcher('*.JS')('a.js', 'a.js')); +});