find files
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
68
src/glob.js
Normal 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
146
src/tools/find-files.js
Normal 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))}`);
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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
61
test/glob.test.js
Normal 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'));
|
||||
});
|
||||
Reference in New Issue
Block a user