@ff-labs/fff-bun 0.1.0-nightly.044314f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/finder.ts ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * FileFinder - High-level API for the fff file finder
3
+ *
4
+ * This class provides a type-safe, ergonomic API for file finding operations.
5
+ * All methods return Result types for explicit error handling.
6
+ */
7
+
8
+ import {
9
+ ffiInit,
10
+ ffiDestroy,
11
+ ffiSearch,
12
+ ffiLiveGrep,
13
+ ffiScanFiles,
14
+ ffiIsScanning,
15
+ ffiGetScanProgress,
16
+ ffiWaitForScan,
17
+ ffiRestartIndex,
18
+ ffiTrackAccess,
19
+ ffiRefreshGitStatus,
20
+ ffiTrackQuery,
21
+ ffiGetHistoricalQuery,
22
+ ffiHealthCheck,
23
+ ensureLoaded,
24
+ isAvailable,
25
+ } from "./ffi";
26
+
27
+ import type {
28
+ Result,
29
+ InitOptions,
30
+ SearchOptions,
31
+ SearchResult,
32
+ ScanProgress,
33
+ HealthCheck,
34
+ GrepOptions,
35
+ GrepResult,
36
+ } from "./types";
37
+
38
+ import { err, toInternalInitOptions, toInternalSearchOptions, toInternalGrepOptions, createGrepCursor } from "./types";
39
+
40
+ /**
41
+ * FileFinder - Fast file finder with fuzzy search
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * import { FileFinder } from "fff";
46
+ *
47
+ * // Initialize
48
+ * const result = FileFinder.init({ basePath: "/path/to/project" });
49
+ * if (!result.ok) {
50
+ * console.error(result.error);
51
+ * process.exit(1);
52
+ * }
53
+ *
54
+ * // Wait for initial scan
55
+ * FileFinder.waitForScan(5000);
56
+ *
57
+ * // Search for files
58
+ * const search = FileFinder.search("main.ts");
59
+ * if (search.ok) {
60
+ * for (const item of search.value.items) {
61
+ * console.log(item.relativePath);
62
+ * }
63
+ * }
64
+ *
65
+ * // Cleanup
66
+ * FileFinder.destroy();
67
+ * ```
68
+ */
69
+ export class FileFinder {
70
+ private static initialized = false;
71
+
72
+ /**
73
+ * Initialize the file finder with the given options.
74
+ *
75
+ * @param options - Initialization options
76
+ * @returns Result indicating success or failure
77
+ *
78
+ * @example
79
+ * ```typescript
80
+ * // Basic initialization
81
+ * FileFinder.init({ basePath: "/path/to/project" });
82
+ *
83
+ * // With custom database paths
84
+ * FileFinder.init({
85
+ * basePath: "/path/to/project",
86
+ * frecencyDbPath: "/custom/frecency.mdb",
87
+ * historyDbPath: "/custom/history.mdb",
88
+ * });
89
+ *
90
+ * // Minimal mode (no databases - just omit db paths)
91
+ * FileFinder.init({ basePath: "/path/to/project" });
92
+ * ```
93
+ */
94
+ static init(options: InitOptions): Result<void> {
95
+ const internalOpts = toInternalInitOptions(options);
96
+ const result = ffiInit(JSON.stringify(internalOpts));
97
+
98
+ if (result.ok) {
99
+ this.initialized = true;
100
+ }
101
+
102
+ return result;
103
+ }
104
+
105
+ /**
106
+ * Destroy and clean up all resources.
107
+ *
108
+ * Call this when you're done using the file finder to free memory
109
+ * and stop background file watching.
110
+ */
111
+ static destroy(): Result<void> {
112
+ const result = ffiDestroy();
113
+ if (result.ok) {
114
+ this.initialized = false;
115
+ }
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * Search for files matching the query.
121
+ *
122
+ * The query supports fuzzy matching and special syntax:
123
+ * - `foo bar` - Match files containing "foo" and "bar"
124
+ * - `src/` - Match files in src directory
125
+ * - `file.ts:42` - Match file.ts with line 42
126
+ * - `file.ts:42:10` - Match file.ts with line 42, column 10
127
+ *
128
+ * @param query - Search query string
129
+ * @param options - Search options
130
+ * @returns Search results with matched files and scores
131
+ *
132
+ * @example
133
+ * ```typescript
134
+ * const result = FileFinder.search("main.ts", { pageSize: 10 });
135
+ * if (result.ok) {
136
+ * console.log(`Found ${result.value.totalMatched} files`);
137
+ * for (const item of result.value.items) {
138
+ * console.log(item.relativePath);
139
+ * }
140
+ * }
141
+ * ```
142
+ */
143
+ static search(query: string, options?: SearchOptions): Result<SearchResult> {
144
+ if (!this.initialized) {
145
+ return err("FileFinder not initialized. Call FileFinder.init() first.");
146
+ }
147
+
148
+ const internalOpts = toInternalSearchOptions(options);
149
+ const result = ffiSearch(query, JSON.stringify(internalOpts));
150
+
151
+ if (!result.ok) {
152
+ return result;
153
+ }
154
+
155
+ // The FFI returns the search result already parsed
156
+ return result as Result<SearchResult>;
157
+ }
158
+
159
+ /**
160
+ * Search file contents (live grep).
161
+ *
162
+ * Searches through the contents of indexed files using the specified mode:
163
+ * - `"plain"` (default): SIMD-accelerated literal text matching
164
+ * - `"regex"`: Regular expression matching
165
+ * - `"fuzzy"`: Smith-Waterman fuzzy matching per line
166
+ *
167
+ * Supports pagination for large result sets. The result includes a `nextCursor`
168
+ * that can be passed back to fetch the next page.
169
+ *
170
+ * The query also supports constraint syntax:
171
+ * - `*.ts pattern` - Only search in TypeScript files
172
+ * - `src/ pattern` - Only search in the src directory
173
+ *
174
+ * @param query - Search query string
175
+ * @param options - Grep options (mode, pagination, limits)
176
+ * @returns Grep results with matched lines and file metadata
177
+ *
178
+ * @example
179
+ * ```typescript
180
+ * // First page
181
+ * const result = FileFinder.liveGrep("TODO", { mode: "plain", pageLimit: 20 });
182
+ * if (result.ok) {
183
+ * for (const match of result.value.items) {
184
+ * console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
185
+ * }
186
+ * // Fetch next page
187
+ * if (result.value.nextCursor) {
188
+ * const page2 = FileFinder.liveGrep("TODO", {
189
+ * cursor: result.value.nextCursor,
190
+ * });
191
+ * }
192
+ * }
193
+ * ```
194
+ */
195
+ static liveGrep(query: string, options?: GrepOptions): Result<GrepResult> {
196
+ if (!this.initialized) {
197
+ return err("FileFinder not initialized. Call FileFinder.init() first.");
198
+ }
199
+
200
+ const internalOpts = toInternalGrepOptions(options);
201
+ const result = ffiLiveGrep(query, JSON.stringify(internalOpts));
202
+
203
+ if (!result.ok) {
204
+ return result;
205
+ }
206
+
207
+ // Transform the raw FFI result: replace nextFileOffset with an opaque cursor
208
+ const raw = result.value as Record<string, unknown>;
209
+ const nextFileOffset = raw.nextFileOffset as number;
210
+
211
+ const grepResult: GrepResult = {
212
+ items: raw.items as GrepResult["items"],
213
+ totalMatched: raw.totalMatched as number,
214
+ totalFilesSearched: raw.totalFilesSearched as number,
215
+ totalFiles: raw.totalFiles as number,
216
+ filteredFileCount: raw.filteredFileCount as number,
217
+ nextCursor: nextFileOffset > 0 ? createGrepCursor(nextFileOffset) : null,
218
+ regexFallbackError: raw.regexFallbackError as string | undefined,
219
+ };
220
+
221
+ return { ok: true, value: grepResult };
222
+ }
223
+
224
+ /**
225
+ * Trigger a rescan of the indexed directory.
226
+ *
227
+ * This is useful after major file system changes that the
228
+ * background watcher might have missed.
229
+ */
230
+ static scanFiles(): Result<void> {
231
+ if (!this.initialized) {
232
+ return err("FileFinder not initialized. Call FileFinder.init() first.");
233
+ }
234
+ return ffiScanFiles();
235
+ }
236
+
237
+ /**
238
+ * Check if a scan is currently in progress.
239
+ */
240
+ static isScanning(): boolean {
241
+ if (!this.initialized) return false;
242
+ return ffiIsScanning();
243
+ }
244
+
245
+ /**
246
+ * Get the current scan progress.
247
+ */
248
+ static getScanProgress(): Result<ScanProgress> {
249
+ if (!this.initialized) {
250
+ return err("FileFinder not initialized. Call FileFinder.init() first.");
251
+ }
252
+ return ffiGetScanProgress() as Result<ScanProgress>;
253
+ }
254
+
255
+ /**
256
+ * Wait for the initial file scan to complete.
257
+ *
258
+ * @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
259
+ * @returns true if scan completed, false if timed out
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * FileFinder.init({ basePath: "/path/to/project" });
264
+ * const completed = FileFinder.waitForScan(10000);
265
+ * if (!completed.ok || !completed.value) {
266
+ * console.warn("Scan did not complete in time");
267
+ * }
268
+ * ```
269
+ */
270
+ static waitForScan(timeoutMs: number = 5000): Result<boolean> {
271
+ if (!this.initialized) {
272
+ return err("FileFinder not initialized. Call FileFinder.init() first.");
273
+ }
274
+ return ffiWaitForScan(timeoutMs);
275
+ }
276
+
277
+ /**
278
+ * Change the indexed directory to a new path.
279
+ *
280
+ * This stops the current file watcher and starts indexing the new directory.
281
+ *
282
+ * @param newPath - New directory path to index
283
+ */
284
+ static reindex(newPath: string): Result<void> {
285
+ if (!this.initialized) {
286
+ return err("FileFinder not initialized. Call FileFinder.init() first.");
287
+ }
288
+ return ffiRestartIndex(newPath);
289
+ }
290
+
291
+ /**
292
+ * Track file access for frecency scoring.
293
+ *
294
+ * Call this when a user opens a file to improve future search rankings.
295
+ *
296
+ * @param filePath - Absolute path to the accessed file
297
+ */
298
+ static trackAccess(filePath: string): Result<boolean> {
299
+ if (!this.initialized) {
300
+ return { ok: true, value: false };
301
+ }
302
+ return ffiTrackAccess(filePath);
303
+ }
304
+
305
+ /**
306
+ * Refresh the git status cache.
307
+ *
308
+ * @returns Number of files with updated git status
309
+ */
310
+ static refreshGitStatus(): Result<number> {
311
+ if (!this.initialized) {
312
+ return err("FileFinder not initialized. Call FileFinder.init() first.");
313
+ }
314
+ return ffiRefreshGitStatus();
315
+ }
316
+
317
+ /**
318
+ * Track query completion for smart suggestions.
319
+ *
320
+ * Call this when a user selects a file from search results.
321
+ * This helps improve future search rankings for similar queries.
322
+ *
323
+ * @param query - The search query that was used
324
+ * @param selectedFilePath - The file path that was selected
325
+ */
326
+ static trackQuery(query: string, selectedFilePath: string): Result<boolean> {
327
+ if (!this.initialized) {
328
+ return { ok: true, value: false };
329
+ }
330
+ return ffiTrackQuery(query, selectedFilePath);
331
+ }
332
+
333
+ /**
334
+ * Get a historical query by offset.
335
+ *
336
+ * @param offset - Offset from most recent (0 = most recent)
337
+ * @returns The historical query string, or null if not found
338
+ */
339
+ static getHistoricalQuery(offset: number): Result<string | null> {
340
+ if (!this.initialized) {
341
+ return { ok: true, value: null };
342
+ }
343
+ return ffiGetHistoricalQuery(offset);
344
+ }
345
+
346
+ /**
347
+ * Get health check information.
348
+ *
349
+ * Useful for debugging and verifying the file finder is working correctly.
350
+ *
351
+ * @param testPath - Optional path to test git repository detection
352
+ */
353
+ static healthCheck(testPath?: string): Result<HealthCheck> {
354
+ return ffiHealthCheck(testPath || "") as Result<HealthCheck>;
355
+ }
356
+
357
+ /**
358
+ * Check if the native library is available.
359
+ */
360
+ static isAvailable(): boolean {
361
+ return isAvailable();
362
+ }
363
+
364
+ /**
365
+ * Ensure the native library is loaded.
366
+ *
367
+ * This will download the binary if needed and load it.
368
+ * Useful for preloading before first use.
369
+ */
370
+ static async ensureLoaded(): Promise<void> {
371
+ return ensureLoaded();
372
+ }
373
+
374
+ /**
375
+ * Check if the file finder is initialized.
376
+ */
377
+ static isInitialized(): boolean {
378
+ return this.initialized;
379
+ }
380
+ }
@@ -0,0 +1,230 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
+ import { FileFinder } from "./index";
3
+ import { findBinary, getDevBinaryPath } from "./download";
4
+ import { getTriple, getLibExtension, getLibFilename } from "./platform";
5
+
6
+ const testDir = process.cwd();
7
+
8
+ describe("Platform Detection", () => {
9
+ test("getTriple returns valid triple", () => {
10
+ const triple = getTriple();
11
+ expect(triple).toMatch(
12
+ /^(x86_64|aarch64|arm)-(apple-darwin|unknown-linux-(gnu|musl)|pc-windows-msvc)$/,
13
+ );
14
+ });
15
+
16
+ test("getLibExtension returns correct extension", () => {
17
+ const ext = getLibExtension();
18
+ const platform = process.platform;
19
+
20
+ if (platform === "darwin") {
21
+ expect(ext).toBe("dylib");
22
+ } else if (platform === "win32") {
23
+ expect(ext).toBe("dll");
24
+ } else {
25
+ expect(ext).toBe("so");
26
+ }
27
+ });
28
+
29
+ test("getLibFilename returns correct filename", () => {
30
+ const filename = getLibFilename();
31
+ const ext = getLibExtension();
32
+
33
+ if (process.platform === "win32") {
34
+ expect(filename).toBe(`fff_c.${ext}`);
35
+ } else {
36
+ expect(filename).toBe(`libfff_c.${ext}`);
37
+ }
38
+ });
39
+ });
40
+
41
+ describe("Binary Detection", () => {
42
+ test("getDevBinaryPath finds local build", () => {
43
+ const devPath = getDevBinaryPath();
44
+ expect(devPath).not.toBeNull();
45
+ expect(devPath).toContain("target/release");
46
+ });
47
+
48
+ test("findBinary returns a path", () => {
49
+ const path = findBinary();
50
+ expect(path).not.toBeNull();
51
+ });
52
+ });
53
+
54
+ describe("FileFinder - Health Check", () => {
55
+ test("healthCheck works before initialization", () => {
56
+ // Make sure we start fresh
57
+ FileFinder.destroy();
58
+
59
+ const result = FileFinder.healthCheck();
60
+ expect(result.ok).toBe(true);
61
+
62
+ if (result.ok) {
63
+ expect(result.value.version).toBeDefined();
64
+ expect(result.value.git.available).toBe(true);
65
+ expect(result.value.filePicker.initialized).toBe(false);
66
+ }
67
+ });
68
+ });
69
+
70
+ describe("FileFinder - Full Lifecycle", () => {
71
+ // Single beforeAll/afterAll for the entire test suite to avoid repeated init/destroy
72
+ beforeAll(() => {
73
+ FileFinder.destroy(); // Clean any previous state
74
+ });
75
+
76
+ afterAll(() => {
77
+ FileFinder.destroy();
78
+ });
79
+
80
+ test("init succeeds with valid path", () => {
81
+ const result = FileFinder.init({
82
+ basePath: testDir,
83
+ });
84
+
85
+ expect(result.ok).toBe(true);
86
+ expect(FileFinder.isInitialized()).toBe(true);
87
+ });
88
+
89
+ test("isScanning returns a boolean", () => {
90
+ const scanning = FileFinder.isScanning();
91
+ expect(typeof scanning).toBe("boolean");
92
+ });
93
+
94
+ test("getScanProgress returns valid data", () => {
95
+ const result = FileFinder.getScanProgress();
96
+ expect(result.ok).toBe(true);
97
+
98
+ if (result.ok) {
99
+ expect(typeof result.value.scannedFilesCount).toBe("number");
100
+ expect(typeof result.value.isScanning).toBe("boolean");
101
+ }
102
+ });
103
+
104
+ test("waitForScan completes", () => {
105
+ // Small timeout - scan should be fast or already done
106
+ const result = FileFinder.waitForScan(500);
107
+ expect(result.ok).toBe(true);
108
+ });
109
+
110
+ test("search with empty query returns all files", () => {
111
+ const result = FileFinder.search("");
112
+ expect(result.ok).toBe(true);
113
+
114
+ if (result.ok) {
115
+ // Empty query should return files (frecency-sorted)
116
+ expect(result.value.totalFiles).toBeGreaterThan(0);
117
+ }
118
+ });
119
+
120
+ test("search returns a valid result structure", () => {
121
+ const result = FileFinder.search("Cargo.toml");
122
+ expect(result.ok).toBe(true);
123
+
124
+ if (result.ok) {
125
+ expect(typeof result.value.totalMatched).toBe("number");
126
+ expect(typeof result.value.totalFiles).toBe("number");
127
+ expect(Array.isArray(result.value.items)).toBe(true);
128
+ expect(Array.isArray(result.value.scores)).toBe(true);
129
+ }
130
+ });
131
+
132
+ test("search returns empty for non-matching query", () => {
133
+ const result = FileFinder.search("xyznonexistentfilenamexyz123456");
134
+ expect(result.ok).toBe(true);
135
+
136
+ if (result.ok) {
137
+ expect(result.value.totalMatched).toBe(0);
138
+ expect(result.value.items.length).toBe(0);
139
+ }
140
+ });
141
+
142
+ test("search respects pageSize option", () => {
143
+ const result = FileFinder.search("ts", { pageSize: 3 });
144
+ expect(result.ok).toBe(true);
145
+
146
+ if (result.ok) {
147
+ expect(result.value.items.length).toBeLessThanOrEqual(3);
148
+ }
149
+ });
150
+
151
+ test("healthCheck shows initialized state", () => {
152
+ const result = FileFinder.healthCheck();
153
+ expect(result.ok).toBe(true);
154
+
155
+ if (result.ok) {
156
+ expect(result.value.filePicker.initialized).toBe(true);
157
+ expect(result.value.filePicker.basePath).toBeDefined();
158
+ expect(typeof result.value.filePicker.indexedFiles).toBe("number");
159
+ }
160
+ });
161
+
162
+ test("healthCheck detects git repository", () => {
163
+ const result = FileFinder.healthCheck(testDir);
164
+ expect(result.ok).toBe(true);
165
+
166
+ if (result.ok) {
167
+ expect(result.value.git.available).toBe(true);
168
+ expect(typeof result.value.git.repositoryFound).toBe("boolean");
169
+ }
170
+ });
171
+
172
+ test("destroy and re-init works", () => {
173
+ FileFinder.destroy();
174
+ expect(FileFinder.isInitialized()).toBe(false);
175
+
176
+ const result = FileFinder.init({
177
+ basePath: testDir,
178
+ });
179
+ expect(result.ok).toBe(true);
180
+ expect(FileFinder.isInitialized()).toBe(true);
181
+ });
182
+ });
183
+
184
+ describe("FileFinder - Error Handling", () => {
185
+ test("search fails when not initialized", () => {
186
+ FileFinder.destroy();
187
+
188
+ const result = FileFinder.search("test");
189
+ expect(result.ok).toBe(false);
190
+ if (!result.ok) {
191
+ expect(result.error).toContain("not initialized");
192
+ }
193
+ });
194
+
195
+ test("getScanProgress fails when not initialized", () => {
196
+ const result = FileFinder.getScanProgress();
197
+ expect(result.ok).toBe(false);
198
+ });
199
+
200
+ test("init fails with invalid path", () => {
201
+ const result = FileFinder.init({
202
+ basePath: "/nonexistent/path/that/does/not/exist",
203
+ });
204
+
205
+ expect(result.ok).toBe(false);
206
+ if (!result.ok) {
207
+ expect(result.error).toContain("Failed");
208
+ }
209
+ });
210
+ });
211
+
212
+ describe("Result Type Helpers", () => {
213
+ test("ok helper creates success result", async () => {
214
+ const { ok } = await import("./types");
215
+ const result = ok(42);
216
+ expect(result.ok).toBe(true);
217
+ if (result.ok) {
218
+ expect(result.value).toBe(42);
219
+ }
220
+ });
221
+
222
+ test("err helper creates error result", async () => {
223
+ const { err } = await import("./types");
224
+ const result = err<number>("something went wrong");
225
+ expect(result.ok).toBe(false);
226
+ if (!result.ok) {
227
+ expect(result.error).toBe("something went wrong");
228
+ }
229
+ });
230
+ });
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * fff - Fast File Finder
3
+ *
4
+ * High-performance fuzzy file finder for Bun, powered by Rust.
5
+ * Perfect for LLM agent tools that need to search through codebases.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { FileFinder } from "fff";
10
+ *
11
+ * // Initialize with a directory
12
+ * const result = FileFinder.init({ basePath: "/path/to/project" });
13
+ * if (!result.ok) {
14
+ * console.error(result.error);
15
+ * process.exit(1);
16
+ * }
17
+ *
18
+ * // Wait for initial scan
19
+ * FileFinder.waitForScan(5000);
20
+ *
21
+ * // Search for files
22
+ * const search = FileFinder.search("main.ts");
23
+ * if (search.ok) {
24
+ * for (const item of search.value.items) {
25
+ * console.log(item.relativePath);
26
+ * }
27
+ * }
28
+ *
29
+ * // Track file access (for frecency)
30
+ * FileFinder.trackAccess("/path/to/project/src/main.ts");
31
+ *
32
+ * // Cleanup when done
33
+ * FileFinder.destroy();
34
+ * ```
35
+ *
36
+ * @packageDocumentation
37
+ */
38
+
39
+ // Main API
40
+ export { FileFinder } from "./finder";
41
+
42
+ // Types
43
+ export type {
44
+ Result,
45
+ InitOptions,
46
+ SearchOptions,
47
+ FileItem,
48
+ Score,
49
+ Location,
50
+ SearchResult,
51
+ ScanProgress,
52
+ HealthCheck,
53
+ DbHealth,
54
+ GrepMode,
55
+ GrepOptions,
56
+ GrepMatch,
57
+ GrepResult,
58
+ GrepCursor,
59
+ } from "./types";
60
+
61
+ // Result helpers
62
+ export { ok, err } from "./types";
63
+
64
+ // Binary management (for CLI tools)
65
+ export {
66
+ downloadBinary,
67
+ ensureBinary,
68
+ binaryExists,
69
+ getBinaryPath,
70
+ findBinary,
71
+ } from "./download";
72
+
73
+ // Platform utilities
74
+ export { getTriple, getLibExtension, getLibFilename, getNpmPackageName } from "./platform";