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 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

View File

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

View File

@@ -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.

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 { 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],

View File

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

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