find files

This commit is contained in:
seb
2026-09-23 07:23:03 +02:00
parent e416ca09c1
commit 917d3d455c
8 changed files with 328 additions and 57 deletions

View File

@@ -1,7 +1,8 @@
**FIDO** a minimal coding assistant. **FIDO** a minimal coding assistant.
Fido is a console REPL assistant that inspects files, lists directories, searches file Fido is a console REPL assistant that inspects files, lists directories, finds files
contents, applies changes, and runs shell commands (with user approval). and directories by name, searches file contents, applies changes, and runs shell
commands (with user approval).
## Install ## Install

View File

@@ -117,3 +117,46 @@ export function walkFiles(dir) {
walk(dir); walk(dir);
return results; 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;
}

View File

@@ -110,6 +110,7 @@ ${dirSection}
- \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing. - \`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. - \`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). - \`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. - \`patch(operations)\`: apply file edits/creates.
- create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created). - create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created).
- search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` — replaces FIRST hit. - search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` — replaces FIRST hit.

68
src/glob.js Normal file
View File

@@ -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;
};
}

146
src/tools/find-files.js Normal file
View File

@@ -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))}`);
}

View File

@@ -9,6 +9,7 @@
*/ */
import { listFilesTool, notifyCall as listFilesNotify, notifyResult as listFilesResult } from './list-files.js'; 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 { readFileTool, notifyCall as readFileNotify, notifyResult as readFileResult } from './read-file.js';
import { ripgrepTool, notifyCall as ripgrepNotify, notifyResult as ripgrepResult } from './ripgrep.js'; import { ripgrepTool, notifyCall as ripgrepNotify, notifyResult as ripgrepResult } from './ripgrep.js';
import { patchTool, notifyCall as patchNotify } from './patch.js'; import { patchTool, notifyCall as patchNotify } from './patch.js';
@@ -23,6 +24,7 @@ import { askExpertTool, notifyCall as askExpertNotify } from './ask-expert.js';
export { export {
listFilesTool, listFilesTool,
findFilesTool,
readFileTool, readFileTool,
ripgrepTool, ripgrepTool,
patchTool, patchTool,
@@ -38,6 +40,7 @@ export {
export const tools = [ export const tools = [
listFilesTool, listFilesTool,
findFilesTool,
readFileTool, readFileTool,
ripgrepTool, ripgrepTool,
patchTool, patchTool,
@@ -59,6 +62,7 @@ export const tools = [
export const toolCallNotifications = Object.fromEntries( export const toolCallNotifications = Object.fromEntries(
[ [
[listFilesTool, listFilesNotify], [listFilesTool, listFilesNotify],
[findFilesTool, findFilesNotify],
[readFileTool, readFileNotify], [readFileTool, readFileNotify],
[ripgrepTool, ripgrepNotify], [ripgrepTool, ripgrepNotify],
[patchTool, patchNotify], [patchTool, patchNotify],
@@ -82,6 +86,7 @@ export const toolCallNotifications = Object.fromEntries(
export const toolResultNotifications = Object.fromEntries( export const toolResultNotifications = Object.fromEntries(
[ [
[listFilesTool, listFilesResult], [listFilesTool, listFilesResult],
[findFilesTool, findFilesResult],
[readFileTool, readFileResult], [readFileTool, readFileResult],
[ripgrepTool, ripgrepResult], [ripgrepTool, ripgrepResult],
[bashExecTool, bashExecResult], [bashExecTool, bashExecResult],

View File

@@ -13,6 +13,7 @@ import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { ROOT_DIR } from '../config.js'; import { ROOT_DIR } from '../config.js';
import { walkFiles } from '../dir-listing.js'; import { walkFiles } from '../dir-listing.js';
import { escapeRegExp, makeGlobMatcher } from '../glob.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
// Files larger than this are skipped during a search to keep things responsive. // Files larger than this are skipped during a search to keep things responsive.
@@ -24,61 +25,6 @@ const MAX_MATCH_TEXT_LEN = 300;
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── 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 * True when a workspace-relative path (forward slashes) lives inside a
* dot-directory — i.e. any *directory* segment starts with ".". Traversal into * dot-directory — i.e. any *directory* segment starts with ".". Traversal into

61
test/glob.test.js Normal file
View File

@@ -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'));
});