@ff-labs/fff-bun 0.9.4-nightly.648f016 → 0.9.4

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/README.md CHANGED
@@ -29,6 +29,26 @@ The correct native binary for your platform is installed automatically via platf
29
29
 
30
30
  If the platform package isn't available, the postinstall script will attempt to download from GitHub releases as a fallback.
31
31
 
32
+ ### Standalone executables (`bun build --compile`)
33
+
34
+ `@ff-labs/fff-bun` embeds the native library into single-file executables built
35
+ with `bun build --compile`. macOS and Windows work with no extra flags:
36
+
37
+ ```bash
38
+ bun build --compile ./app.ts --outfile myapp
39
+ ```
40
+
41
+ On **Linux** the C library's libc (glibc vs musl) can't be detected at build
42
+ time, so you must pass it as a build constant for the lib to embed:
43
+
44
+ ```bash
45
+ bun build --compile --define FFF_LIBC='"gnu"' ./app.ts --outfile myapp # glibc
46
+ bun build --compile --define FFF_LIBC='"musl"' ./app.ts --outfile myapp # musl / Alpine
47
+ ```
48
+
49
+ Without the define on Linux the library is resolved at runtime instead of being
50
+ embedded, which works under `bun run` but not in a standalone binary.
51
+
32
52
  ## Quick Start
33
53
 
34
54
  Each `FileFinder` instance owns an independent native index. Create one, wait
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ff-labs/fff-bun",
3
- "version": "0.9.4-nightly.648f016",
3
+ "version": "0.9.4",
4
4
  "private": false,
5
5
  "description": "High-performance fuzzy file finder for Bun - perfect for LLM agent tools",
6
6
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "examples"
18
18
  ],
19
19
  "scripts": {
20
- "test": "bun test src/",
20
+ "test": "bun test test/",
21
21
  "typecheck": "tsc --noEmit",
22
22
  "demo": "bun ./examples/search.ts"
23
23
  },
@@ -58,14 +58,14 @@
58
58
  },
59
59
  "homepage": "https://github.com/dmtrKovalenko/fff#readme",
60
60
  "optionalDependencies": {
61
- "@ff-labs/fff-bin-darwin-arm64": "0.9.4-nightly.648f016",
62
- "@ff-labs/fff-bin-darwin-x64": "0.9.4-nightly.648f016",
63
- "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4-nightly.648f016",
64
- "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4-nightly.648f016",
65
- "@ff-labs/fff-bin-linux-x64-musl": "0.9.4-nightly.648f016",
66
- "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4-nightly.648f016",
67
- "@ff-labs/fff-bin-win32-x64": "0.9.4-nightly.648f016",
68
- "@ff-labs/fff-bin-win32-arm64": "0.9.4-nightly.648f016"
61
+ "@ff-labs/fff-bin-darwin-arm64": "0.9.4",
62
+ "@ff-labs/fff-bin-darwin-x64": "0.9.4",
63
+ "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4",
64
+ "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4",
65
+ "@ff-labs/fff-bin-linux-x64-musl": "0.9.4",
66
+ "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4",
67
+ "@ff-labs/fff-bin-win32-x64": "0.9.4",
68
+ "@ff-labs/fff-bin-win32-arm64": "0.9.4"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@types/bun": "^1.3.8",
package/src/embed.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Native library imports embedded via `bun build --compile` (type: "file").
2
+ // Each resolves to the embedded file's path string at runtime.
3
+ declare module "*.so" {
4
+ const path: string;
5
+ export default path;
6
+ }
7
+ declare module "*.dylib" {
8
+ const path: string;
9
+ export default path;
10
+ }
11
+ declare module "*.dll" {
12
+ const path: string;
13
+ export default path;
14
+ }
15
+
16
+ // Build-time constant injected via `bun build --define FFF_LIBC='"musl"'`.
17
+ declare const FFF_LIBC: "gnu" | "musl";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Tools for standalone-executable native library embedding.
3
+ *
4
+ * `bun build --compile` only bundles files referenced by statically analyzable
5
+ * imports. Runtime resolution (./download.ts) is invisible to the bundler, so
6
+ * we additionally reference the platform's native lib through a `type: "file"`
7
+ * import. Bun then embeds it and returns a `$bunfs` path inside the compiled
8
+ * binary (and the real on-disk path under `bun run`).
9
+ *
10
+ * Linux libc cannot be detected at build time, so it is supplied via the
11
+ * FFF_LIBC build constant (`bun build --define FFF_LIBC='"musl"'`), defaulting
12
+ * to glibc. macOS/Windows need no define.
13
+ */
14
+
15
+ async function importFile(promise: Promise<{ default: string }>): Promise<string | null> {
16
+ try {
17
+ return (await promise).default;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ async function resolveEmbeddedLibPath(): Promise<string | null> {
24
+ if (process.platform === "darwin") {
25
+ return importFile(
26
+ import(`@ff-labs/fff-bin-darwin-${process.arch}/libfff_c.dylib`, {
27
+ with: { type: "file" },
28
+ }),
29
+ );
30
+ }
31
+
32
+ if (process.platform === "win32") {
33
+ return importFile(
34
+ import(`@ff-labs/fff-bin-win32-${process.arch}/fff_c.dll`, {
35
+ with: { type: "file" },
36
+ }),
37
+ );
38
+ }
39
+
40
+ if (process.platform === "linux") {
41
+ return importFile(
42
+ import(
43
+ `@ff-labs/fff-bin-linux-${process.arch}-${typeof FFF_LIBC === "string" ? FFF_LIBC : "gnu"}/libfff_c.so`,
44
+ { with: { type: "file" } }
45
+ ),
46
+ );
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ // Resolved once at module init so loadLibrary() can stay synchronous.
53
+ export const embeddedLibPath: string | null = await resolveEmbeddedLibPath();
package/src/ffi.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { CString, dlopen, FFIType, type Pointer, ptr, read } from "bun:ffi";
12
12
  import { findBinary } from "./download";
13
+ import { embeddedLibPath } from "./embedded";
13
14
  import type {
14
15
  DirItem,
15
16
  DirSearchResult,
@@ -290,17 +291,36 @@ let lib: FFFLibrary | null = null;
290
291
  function loadLibrary(): FFFLibrary {
291
292
  if (lib) return lib;
292
293
 
293
- const binaryPath = findBinary();
294
+ const isEmbedded = embeddedLibPath?.includes("$bunfs") ?? false;
295
+ const binaryPath = isEmbedded
296
+ ? embeddedLibPath
297
+ : (findBinary() ?? embeddedLibPath);
294
298
  if (!binaryPath) {
295
- throw new Error(
296
- "fff native library not found. Build from source with `cargo build --release -p fff-c` or install the platform package.",
297
- );
299
+ throw new Error(libNotFoundMessage());
298
300
  }
299
301
 
300
302
  lib = dlopen(binaryPath, ffiDefinition);
301
303
  return lib;
302
304
  }
303
305
 
306
+ function libNotFoundMessage(): string {
307
+ if (import.meta.url.includes("$bunfs")) {
308
+ if (process.platform === "linux") {
309
+ return [
310
+ "You are running bun --compile with fff native library which CAN NOT resolve a binary",
311
+ "On Linux the libc must be supplied at compile time so the native lib is bundled.",
312
+ "Rebuild with:",
313
+ " bun build --compile --define FFF_LIBC='\"gnu\"' ... # glibc",
314
+ " bun build --compile --define FFF_LIBC='\"musl\"' ... # musl / Alpine",
315
+ ].join("\n");
316
+ }
317
+
318
+ return "fff native library was not embedded into this executable. Rebuild with `bun build --compile` and ensure the @ff-labs/fff-bin-* package for this platform is installed.";
319
+ }
320
+
321
+ return "fff native library not found. Build from source with `cargo build --release -p fff-c` or install the platform package.";
322
+ }
323
+
304
324
  /**
305
325
  * Encode a string for FFI (null-terminated)
306
326
  */
@@ -329,7 +349,9 @@ function snakeToCamel(obj: unknown): unknown {
329
349
 
330
350
  const result: Record<string, unknown> = {};
331
351
  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
332
- const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
352
+ const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
353
+ letter.toUpperCase(),
354
+ );
333
355
  result[camelKey] = snakeToCamel(value);
334
356
  }
335
357
  return result;
@@ -424,7 +446,8 @@ function parseJsonResult<T>(resultPtr: Pointer | null): Result<T> {
424
446
  const jsonStr = readCString(envelope.handlePtr);
425
447
  library.symbols.fff_free_string(asPtr(envelope.handlePtr));
426
448
 
427
- if (jsonStr === null || jsonStr === "") return { ok: true, value: undefined as T };
449
+ if (jsonStr === null || jsonStr === "")
450
+ return { ok: true, value: undefined as T };
428
451
 
429
452
  try {
430
453
  return { ok: true, value: snakeToCamel(JSON.parse(jsonStr)) as T };
@@ -725,7 +748,9 @@ function readDirItemStruct(p: number): DirItem {
725
748
  /**
726
749
  * Parse an FffDirSearchResult from a raw FffResult pointer, then free native memory.
727
750
  */
728
- function parseDirSearchResult(resultPtr: Pointer | null): Result<DirSearchResult> {
751
+ function parseDirSearchResult(
752
+ resultPtr: Pointer | null,
753
+ ): Result<DirSearchResult> {
729
754
  if (resultPtr === null) {
730
755
  return err("FFI returned null pointer");
731
756
  }
@@ -828,7 +853,9 @@ function readMixedItemStruct(p: number): MixedItem {
828
853
  /**
829
854
  * Parse an FffMixedSearchResult from a raw FffResult pointer, then free native memory.
830
855
  */
831
- function parseMixedSearchResult(resultPtr: Pointer | null): Result<MixedSearchResult> {
856
+ function parseMixedSearchResult(
857
+ resultPtr: Pointer | null,
858
+ ): Result<MixedSearchResult> {
832
859
  if (resultPtr === null) {
833
860
  return err("FFI returned null pointer");
834
861
  }
@@ -860,7 +887,10 @@ function parseMixedSearchResult(resultPtr: Pointer | null): Result<MixedSearchRe
860
887
  } else if (locTag === 3) {
861
888
  location = {
862
889
  type: "range",
863
- start: { line: read.i32(hp, MSR_LOC_LINE), col: read.i32(hp, MSR_LOC_COL) },
890
+ start: {
891
+ line: read.i32(hp, MSR_LOC_LINE),
892
+ col: read.i32(hp, MSR_LOC_COL),
893
+ },
864
894
  end: {
865
895
  line: read.i32(hp, MSR_LOC_END_LINE),
866
896
  col: read.i32(hp, MSR_LOC_END_COL),
@@ -1006,10 +1036,16 @@ function readGrepMatchStruct(p: number): GrepMatch {
1006
1036
  match.fuzzyScore = read.u16(pp, GM_FUZZY_SCORE);
1007
1037
  }
1008
1038
  if (ctxBeforeCount > 0) {
1009
- match.contextBefore = readCStringArray(read.ptr(pp, GM_CTX_BEFORE), ctxBeforeCount);
1039
+ match.contextBefore = readCStringArray(
1040
+ read.ptr(pp, GM_CTX_BEFORE),
1041
+ ctxBeforeCount,
1042
+ );
1010
1043
  }
1011
1044
  if (ctxAfterCount > 0) {
1012
- match.contextAfter = readCStringArray(read.ptr(pp, GM_CTX_AFTER), ctxAfterCount);
1045
+ match.contextAfter = readCStringArray(
1046
+ read.ptr(pp, GM_CTX_AFTER),
1047
+ ctxAfterCount,
1048
+ );
1013
1049
  }
1014
1050
  if (read.u8(pp, GM_IS_DEFINITION) !== 0) {
1015
1051
  match.isDefinition = true;
@@ -1291,18 +1327,30 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<ScanProgress> {
1291
1327
  /**
1292
1328
  * Wait for scan to complete.
1293
1329
  */
1294
- export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result<boolean> {
1330
+ export function ffiWaitForScan(
1331
+ handle: NativeHandle,
1332
+ timeoutMs: number,
1333
+ ): Result<boolean> {
1295
1334
  const library = loadLibrary();
1296
- const resultPtr = library.symbols.fff_wait_for_scan(handle, BigInt(timeoutMs));
1335
+ const resultPtr = library.symbols.fff_wait_for_scan(
1336
+ handle,
1337
+ BigInt(timeoutMs),
1338
+ );
1297
1339
  return parseBoolResult(resultPtr);
1298
1340
  }
1299
1341
 
1300
1342
  /**
1301
1343
  * Restart index in new path.
1302
1344
  */
1303
- export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result<void> {
1345
+ export function ffiRestartIndex(
1346
+ handle: NativeHandle,
1347
+ newPath: string,
1348
+ ): Result<void> {
1304
1349
  const library = loadLibrary();
1305
- const resultPtr = library.symbols.fff_restart_index(handle, ptr(encodeString(newPath)));
1350
+ const resultPtr = library.symbols.fff_restart_index(
1351
+ handle,
1352
+ ptr(encodeString(newPath)),
1353
+ );
1306
1354
  return parseVoidResult(resultPtr);
1307
1355
  }
1308
1356
 
@@ -1340,7 +1388,10 @@ export function ffiGetHistoricalQuery(
1340
1388
  offset: number,
1341
1389
  ): Result<string | null> {
1342
1390
  const library = loadLibrary();
1343
- const resultPtr = library.symbols.fff_get_historical_query(handle, BigInt(offset));
1391
+ const resultPtr = library.symbols.fff_get_historical_query(
1392
+ handle,
1393
+ BigInt(offset),
1394
+ );
1344
1395
  return parseStringResult(resultPtr);
1345
1396
  }
1346
1397
 
@@ -1,367 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { execSync } from "node:child_process";
3
- import {
4
- mkdirSync,
5
- mkdtempSync,
6
- realpathSync,
7
- rmSync,
8
- unlinkSync,
9
- writeFileSync,
10
- } from "node:fs";
11
- import { tmpdir } from "node:os";
12
- import { join } from "node:path";
13
- import type { FileItem } from "./fff-api";
14
- import { FileFinder } from "./index";
15
-
16
- /**
17
- * Integration test: full git lifecycle with a real repository.
18
- *
19
- * Creates a temporary git repo, initialises a FileFinder instance pointing at
20
- * it, then walks through:
21
- * 1. Initial scan – committed files should have status "clean"
22
- * 2. Add a new untracked file – should appear as "untracked"
23
- * 3. Stage the new file – should appear as "staged_new"
24
- * 4. Commit – should become "clean"
25
- * 5. Modify a tracked file – should become "modified"
26
- * 6. Stage the modification – should become "staged_modified"
27
- * 7. Commit again – back to "clean"
28
- * 8. Delete a file – should disappear from the index
29
- */
30
-
31
- const POLL_INTERVAL_MS = 100;
32
- const WATCHER_TIMEOUT_MS = 5_000; // generous CI timeout; polls exit early on fast machines
33
-
34
- function git(cwd: string, ...args: string[]) {
35
- const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
36
- execSync(`git ${escaped}`, {
37
- cwd,
38
- stdio: "pipe",
39
- env: {
40
- ...process.env,
41
- GIT_AUTHOR_NAME: "test",
42
- GIT_AUTHOR_EMAIL: "test@test.com",
43
- GIT_COMMITTER_NAME: "test",
44
- GIT_COMMITTER_EMAIL: "test@test.com",
45
- },
46
- });
47
- }
48
-
49
- function sleep(ms: number) {
50
- return new Promise((r) => setTimeout(r, ms));
51
- }
52
-
53
- function findFile(finder: FileFinder, name: string): FileItem | undefined {
54
- const result = finder.fileSearch(name, { pageSize: 200 });
55
- if (!result.ok) throw new Error(`search failed: ${result.error}`);
56
- return result.value.items.find((item) => item.fileName === name);
57
- }
58
-
59
- /** Poll until a file appears in the index, or the timeout is exceeded. */
60
- async function waitForFile(
61
- finder: FileFinder,
62
- name: string,
63
- ): Promise<FileItem | undefined> {
64
- const start = Date.now();
65
- while (Date.now() - start < WATCHER_TIMEOUT_MS) {
66
- const file = findFile(finder, name);
67
- if (file !== undefined) return file;
68
- await sleep(POLL_INTERVAL_MS);
69
- }
70
- return findFile(finder, name);
71
- }
72
-
73
- /** Poll until a file has the expected git status, or the timeout is exceeded. */
74
- async function waitForFileStatus(
75
- finder: FileFinder,
76
- name: string,
77
- status: string,
78
- ): Promise<FileItem | undefined> {
79
- const start = Date.now();
80
- while (Date.now() - start < WATCHER_TIMEOUT_MS) {
81
- const file = findFile(finder, name);
82
- if (file?.gitStatus === status) return file;
83
- await sleep(POLL_INTERVAL_MS);
84
- }
85
- return findFile(finder, name);
86
- }
87
-
88
- /** Poll until a file is gone from the index, or the timeout is exceeded. */
89
- async function waitForFileGone(finder: FileFinder, name: string): Promise<boolean> {
90
- const start = Date.now();
91
- while (Date.now() - start < WATCHER_TIMEOUT_MS) {
92
- if (findFile(finder, name) === undefined) return true;
93
- await sleep(POLL_INTERVAL_MS);
94
- }
95
- return findFile(finder, name) === undefined;
96
- }
97
-
98
- /** Poll until the total file count reaches the expected value, or the timeout is exceeded. */
99
- async function waitForFileCount(finder: FileFinder, count: number): Promise<number> {
100
- const start = Date.now();
101
- while (Date.now() - start < WATCHER_TIMEOUT_MS) {
102
- const result = finder.fileSearch("", { pageSize: 200 });
103
- if (result.ok && result.value.totalFiles === count) return count;
104
- await sleep(POLL_INTERVAL_MS);
105
- }
106
- const result = finder.fileSearch("", { pageSize: 200 });
107
- return result.ok ? result.value.totalFiles : -1;
108
- }
109
-
110
- /** Poll grep until predicate on totalMatched is satisfied, or the timeout is exceeded. */
111
- async function waitForGrep(
112
- finder: FileFinder,
113
- pattern: string,
114
- options: { mode: "plain" | "regex" },
115
- predicate: (totalMatched: number) => boolean,
116
- ) {
117
- const start = Date.now();
118
- while (Date.now() - start < WATCHER_TIMEOUT_MS) {
119
- const result = finder.grep(pattern, options);
120
- if (result.ok && predicate(result.value.totalMatched)) return result;
121
- await sleep(POLL_INTERVAL_MS);
122
- }
123
- return finder.grep(pattern, options);
124
- }
125
-
126
- describe.skipIf(process.platform === "win32")("Git lifecycle integration", () => {
127
- let tmpDir: string;
128
- let finder: FileFinder;
129
-
130
- beforeAll(async () => {
131
- // Create temp directory and initialise a git repo with two committed files.
132
- // Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
133
- // that git2's resolved workdir paths match the file picker's base_path.
134
- tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
135
-
136
- git(tmpDir, "init", "-b", "main");
137
- // Need at least one commit for status to work properly
138
- writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
139
- writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
140
- mkdirSync(join(tmpDir, "src"));
141
- writeFileSync(join(tmpDir, "src", "main.rs"), 'fn main() { println?."hi"); }\n');
142
- git(tmpDir, "add", "-A");
143
- git(tmpDir, "commit", "-m", "initial commit");
144
-
145
- // Create the FileFinder instance
146
- const result = FileFinder.create({ basePath: tmpDir });
147
- expect(result.ok).toBe(true);
148
- if (!result.ok) throw new Error(result.error);
149
- finder = result.value;
150
-
151
- // Wait for the initial scan to finish
152
- const scanResult = finder.waitForScanBlocking(10_000);
153
- expect(scanResult.ok).toBe(true);
154
-
155
- // Poll getScanProgress until the watcher is ready so that
156
- // filesystem events (file creates, deletes) are detected.
157
- const start = Date.now();
158
- while (Date.now() - start < WATCHER_TIMEOUT_MS) {
159
- const progress = finder.getScanProgress();
160
- if (progress.ok && progress.value.isWatcherReady) break;
161
- await sleep(POLL_INTERVAL_MS);
162
- }
163
- const progress = finder.getScanProgress();
164
- expect(progress.ok).toBe(true);
165
- if (progress.ok) {
166
- expect(progress.value.isWatcherReady).toBe(true);
167
- }
168
- });
169
-
170
- afterAll(() => {
171
- finder?.destroy();
172
- if (tmpDir) {
173
- rmSync(tmpDir, { recursive: true, force: true });
174
- }
175
- });
176
-
177
- test("initial scan indexes all committed files", () => {
178
- const result = finder.fileSearch("", { pageSize: 200 });
179
- expect(result.ok).toBe(true);
180
- if (!result.ok) return;
181
-
182
- const names = result.value.items.map((i) => i.relativePath).sort();
183
- expect(names).toContain("hello.txt");
184
- expect(names).toContain("readme.md");
185
- expect(names).toContain("src/main.rs");
186
- expect(result.value.totalFiles).toBe(3);
187
- });
188
-
189
- test("committed files have clean git status", async () => {
190
- const hello = await waitForFileStatus(finder, "hello.txt", "clean");
191
- expect(hello).toBeDefined();
192
- expect(hello?.gitStatus).toBe("clean");
193
-
194
- const main = await waitForFileStatus(finder, "main.rs", "clean");
195
- expect(main).toBeDefined();
196
- expect(main?.gitStatus).toBe("clean");
197
- });
198
-
199
- test("new untracked file appears with 'untracked' status", async () => {
200
- writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
201
-
202
- const newFile = await waitForFileStatus(finder, "new_file.ts", "untracked");
203
- expect(newFile).toBeDefined();
204
- expect(newFile?.gitStatus).toBe("untracked");
205
-
206
- // Total should now be 4
207
- const total = await waitForFileCount(finder, 4);
208
- expect(total).toBe(4);
209
- });
210
-
211
- test("staging a new file changes status to 'staged_new'", async () => {
212
- git(tmpDir, "add", "new_file.ts");
213
-
214
- const newFile = await waitForFileStatus(finder, "new_file.ts", "staged_new");
215
- expect(newFile).toBeDefined();
216
- expect(newFile?.gitStatus).toBe("staged_new");
217
- });
218
-
219
- test("committing makes the file 'clean'", async () => {
220
- git(tmpDir, "commit", "-m", "add new_file");
221
-
222
- const newFile = await waitForFileStatus(finder, "new_file.ts", "clean");
223
- expect(newFile).toBeDefined();
224
- expect(newFile?.gitStatus).toBe("clean");
225
- });
226
-
227
- test("modifying a tracked file changes status to 'modified'", async () => {
228
- writeFileSync(join(tmpDir, "hello.txt"), "hello world\nupdated content\n");
229
-
230
- const hello = await waitForFileStatus(finder, "hello.txt", "modified");
231
- expect(hello).toBeDefined();
232
- expect(hello?.gitStatus).toBe("modified");
233
- });
234
-
235
- test("staging a modification changes status to 'staged_modified'", async () => {
236
- git(tmpDir, "add", "hello.txt");
237
-
238
- const hello = await waitForFileStatus(finder, "hello.txt", "staged_modified");
239
- expect(hello).toBeDefined();
240
- expect(hello?.gitStatus).toBe("staged_modified");
241
- });
242
-
243
- test("committing the modification returns to 'clean'", async () => {
244
- git(tmpDir, "commit", "-m", "update hello");
245
-
246
- const hello = await waitForFileStatus(finder, "hello.txt", "clean");
247
- expect(hello).toBeDefined();
248
- expect(hello?.gitStatus).toBe("clean");
249
- });
250
-
251
- test("deleting a file removes it from the index", async () => {
252
- unlinkSync(join(tmpDir, "new_file.ts"));
253
-
254
- const gone = await waitForFileGone(finder, "new_file.ts");
255
- expect(gone).toBe(true);
256
-
257
- // Total should be back to 3
258
- const total = await waitForFileCount(finder, 3);
259
- expect(total).toBe(3);
260
- });
261
-
262
- test("adding a file in a subdirectory works", async () => {
263
- writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
264
-
265
- const utils = await waitForFileStatus(finder, "utils.rs", "untracked");
266
- expect(utils).toBeDefined();
267
- expect(utils?.relativePath).toBe("src/utils.rs");
268
- expect(utils?.gitStatus).toBe("untracked");
269
- });
270
-
271
- test("live grep finds content in a newly added file", async () => {
272
- writeFileSync(
273
- join(tmpDir, "src", "searchtarget.rs"),
274
- 'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
275
- );
276
-
277
- await waitForFile(finder, "searchtarget.rs");
278
-
279
- const result = await waitForGrep(
280
- finder,
281
- "xylophone_waterfall_97",
282
- { mode: "plain" },
283
- (n) => n > 0,
284
- );
285
- expect(result?.ok).toBe(true);
286
- if (!result?.ok) return;
287
-
288
- expect(result.value.totalMatched).toBeGreaterThan(0);
289
- const match = result.value.items.find(
290
- (m) => m.relativePath === "src/searchtarget.rs",
291
- );
292
- expect(match).toBeDefined();
293
- expect(match!.lineContent).toContain("xylophone_waterfall_97");
294
- });
295
-
296
- test("live grep no longer finds content after file is deleted", async () => {
297
- unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
298
-
299
- const result = await waitForGrep(
300
- finder,
301
- "xylophone_waterfall_97",
302
- { mode: "plain" },
303
- (n) => n === 0,
304
- );
305
- expect(result?.ok).toBe(true);
306
- if (!result?.ok) return;
307
-
308
- expect(result.value.totalMatched).toBe(0);
309
- expect(result.value.items.length).toBe(0);
310
- });
311
-
312
- test("file in a newly created directory is discoverable", async () => {
313
- // Create a brand-new directory that didn't exist during the initial scan,
314
- // then add a file inside it. The watcher must dynamically pick up the new
315
- // directory and index the file.
316
- mkdirSync(join(tmpDir, "lib"));
317
- writeFileSync(
318
- join(tmpDir, "lib", "helpers.ts"),
319
- "export function add(a: number, b: number) { return a + b; }\n",
320
- );
321
-
322
- const helpers = await waitForFile(finder, "helpers.ts");
323
- expect(helpers).toBeDefined();
324
- expect(helpers?.relativePath).toBe("lib/helpers.ts");
325
- });
326
-
327
- test("files in gitignored directories are not indexed", async () => {
328
- // Commit a .gitignore rule first so it's established repo state before
329
- // the ignored directory is created. This tests the watch-level filtering
330
- // (is_path_ignored in the debouncer callback), not a rescan triggered
331
- // by a .gitignore change.
332
- writeFileSync(join(tmpDir, ".gitignore"), "build_output/\n");
333
- git(tmpDir, "add", ".gitignore");
334
- git(tmpDir, "commit", "-m", "add gitignore");
335
-
336
- // Wait for the watcher to settle after the commit.
337
- await waitForFile(finder, ".gitignore");
338
-
339
- // Now create the ignored directory and add a file inside it.
340
- mkdirSync(join(tmpDir, "build_output"));
341
- writeFileSync(join(tmpDir, "build_output", "artifact.bin"), "should not appear\n");
342
-
343
- // Create a non-ignored file as a synchronisation barrier — once it's
344
- // indexed, the watcher has processed the same batch of events.
345
- writeFileSync(join(tmpDir, "canary.txt"), "visible\n");
346
- const canary = await waitForFile(finder, "canary.txt");
347
- expect(canary).toBeDefined();
348
-
349
- // The ignored file must NOT appear in the index.
350
- const artifact = findFile(finder, "artifact.bin");
351
- expect(artifact).toBeUndefined();
352
- });
353
-
354
- test("full add-commit cycle for subdirectory file", async () => {
355
- git(tmpDir, "add", "src/utils.rs");
356
-
357
- let utils = await waitForFileStatus(finder, "utils.rs", "staged_new");
358
- expect(utils).toBeDefined();
359
- expect(utils?.gitStatus).toBe("staged_new");
360
-
361
- git(tmpDir, "commit", "-m", "add utils");
362
-
363
- utils = await waitForFileStatus(finder, "utils.rs", "clean");
364
- expect(utils).toBeDefined();
365
- expect(utils?.gitStatus).toBe("clean");
366
- });
367
- });
package/src/index.test.ts DELETED
@@ -1,503 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { findBinary } from "./download";
5
- import { FileFinder } from "./index";
6
- import { getLibExtension, getLibFilename, getTriple } from "./platform";
7
-
8
- // Cross-platform path normalization helpers
9
- const normalizePath = (path: string | null | undefined): string | null => {
10
- if (!path) return null;
11
- // Convert backslashes to forward slashes for consistent comparison
12
- return path.replace(/\\/g, "/");
13
- };
14
-
15
- const testDir = process.cwd();
16
-
17
- describe("Platform Detection", () => {
18
- test("getTriple returns valid triple", () => {
19
- const triple = getTriple();
20
- expect(triple).toMatch(
21
- /^(x86_64|aarch64|arm)-(apple-darwin|unknown-linux-(gnu|musl)|pc-windows-msvc)$/,
22
- );
23
- });
24
-
25
- test("getLibExtension returns correct extension", () => {
26
- const ext = getLibExtension();
27
- const platform = process.platform;
28
-
29
- if (platform === "darwin") {
30
- expect(ext).toBe("dylib");
31
- } else if (platform === "win32") {
32
- expect(ext).toBe("dll");
33
- } else {
34
- expect(ext).toBe("so");
35
- }
36
- });
37
-
38
- test("getLibFilename returns correct filename", () => {
39
- const filename = getLibFilename();
40
- const ext = getLibExtension();
41
-
42
- if (process.platform === "win32") {
43
- expect(filename).toBe(`fff_c.${ext}`);
44
- } else {
45
- expect(filename).toBe(`libfff_c.${ext}`);
46
- }
47
- });
48
- });
49
-
50
- describe("Binary Detection", () => {
51
- test("findBinary returns a path", () => {
52
- const path = findBinary();
53
- expect(path).not.toBeNull();
54
- });
55
- });
56
-
57
- describe("FileFinder - Health Check", () => {
58
- test("healthCheckStatic works without an instance", () => {
59
- const result = FileFinder.healthCheckStatic();
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
- let finder: FileFinder;
72
-
73
- beforeAll(() => {
74
- const result = FileFinder.create({ basePath: testDir });
75
- expect(result.ok).toBe(true);
76
- if (result.ok) {
77
- finder = result.value;
78
- }
79
- });
80
-
81
- afterAll(() => {
82
- finder?.destroy();
83
- });
84
-
85
- test("create succeeds with valid path", () => {
86
- expect(finder).toBeDefined();
87
- expect(finder.isDestroyed).toBe(false);
88
- });
89
-
90
- test("isScanning returns a boolean", () => {
91
- const scanning = finder.isScanning();
92
- expect(typeof scanning).toBe("boolean");
93
- });
94
-
95
- test("getScanProgress returns valid data", () => {
96
- const result = finder.getScanProgress();
97
- expect(result.ok).toBe(true);
98
-
99
- if (result.ok) {
100
- expect(typeof result.value.scannedFilesCount).toBe("number");
101
- expect(typeof result.value.isScanning).toBe("boolean");
102
- }
103
- });
104
-
105
- test("waitForScanBlocking completes", () => {
106
- // Small timeout - scan should be fast or already done
107
- const result = finder.waitForScanBlocking(500);
108
- expect(result.ok).toBe(true);
109
- });
110
-
111
- test("search with empty query returns all files", () => {
112
- // First check scan progress to see if files were indexed
113
- const progress = finder.getScanProgress();
114
- if (progress.ok) {
115
- }
116
-
117
- const result = finder.fileSearch("");
118
- expect(result.ok).toBe(true);
119
-
120
- if (result.ok) {
121
- if (result.value.items.length > 0) {
122
- // Log first few paths to see format on Windows
123
- // Items are strings (file paths), not objects
124
- const _samplePaths = result.value.items
125
- .slice(0, 3)
126
- .map((item) =>
127
- normalizePath(typeof item === "string" ? item : item.relativePath),
128
- );
129
- }
130
- // Empty query should return files (frecency-sorted)
131
- expect(result.value.totalFiles).toBeGreaterThan(0);
132
- } else {
133
- }
134
- });
135
-
136
- test("search returns a valid result structure", () => {
137
- const result = finder.fileSearch("Cargo.toml");
138
- expect(result.ok).toBe(true);
139
-
140
- if (result.ok) {
141
- expect(typeof result.value.totalMatched).toBe("number");
142
- expect(typeof result.value.totalFiles).toBe("number");
143
- expect(Array.isArray(result.value.items)).toBe(true);
144
- expect(Array.isArray(result.value.scores)).toBe(true);
145
- }
146
- });
147
-
148
- test("search returns empty for non-matching query", () => {
149
- const result = finder.fileSearch("xyznonexistentfilenamexyz123456");
150
- expect(result.ok).toBe(true);
151
-
152
- if (result.ok) {
153
- expect(result.value.totalMatched).toBe(0);
154
- expect(result.value.items.length).toBe(0);
155
- }
156
- });
157
-
158
- test("search respects pageSize option", () => {
159
- const result = finder.fileSearch("ts", { pageSize: 3 });
160
- expect(result.ok).toBe(true);
161
-
162
- if (result.ok) {
163
- expect(result.value.items.length).toBeLessThanOrEqual(3);
164
- }
165
- });
166
-
167
- test("glob filters by extension via raw pattern", () => {
168
- const result = finder.glob("**/*.ts", { pageSize: 50 });
169
- expect(result.ok).toBe(true);
170
- if (result.ok) {
171
- expect(result.value.items.length).toBeGreaterThan(0);
172
- for (const item of result.value.items) {
173
- expect(item.relativePath.endsWith(".ts")).toBe(true);
174
- }
175
- }
176
- });
177
-
178
- test("glob returns empty result for non-matching pattern", () => {
179
- const result = finder.glob("**/this-extension-does-not-exist-anywhere.zzz");
180
- expect(result.ok).toBe(true);
181
- if (result.ok) {
182
- expect(result.value.items.length).toBe(0);
183
- }
184
- });
185
-
186
- test("glob rejects empty pattern", () => {
187
- const result = finder.glob("");
188
- expect(result.ok).toBe(false);
189
- });
190
-
191
- test("glob respects pageSize", () => {
192
- const result = finder.glob("**/*.ts", { pageSize: 2 });
193
- expect(result.ok).toBe(true);
194
- if (result.ok) {
195
- expect(result.value.items.length).toBeLessThanOrEqual(2);
196
- }
197
- });
198
-
199
- test("glob pageIndex offsets results", () => {
200
- // pageIndex is a raw item offset (not a page-count multiplier). Verify
201
- // by skipping the first item and checking the second result begins
202
- // where page0[1] left off.
203
- const page0 = finder.glob("**/*.ts", { pageSize: 5, pageIndex: 0 });
204
- const page1 = finder.glob("**/*.ts", { pageSize: 5, pageIndex: 1 });
205
- expect(page0.ok).toBe(true);
206
- expect(page1.ok).toBe(true);
207
- if (
208
- page0.ok &&
209
- page1.ok &&
210
- page0.value.items.length > 1 &&
211
- page1.value.items.length > 0
212
- ) {
213
- expect(page1.value.items[0]!.relativePath).toBe(page0.value.items[1]!.relativePath);
214
- }
215
- });
216
-
217
- test("glob directory-prefix pattern matches only that subtree", () => {
218
- const result = finder.glob("src/**/*.ts", { pageSize: 100 });
219
- expect(result.ok).toBe(true);
220
- if (result.ok) {
221
- for (const item of result.value.items) {
222
- expect(item.relativePath.startsWith("src/")).toBe(true);
223
- expect(item.relativePath.endsWith(".ts")).toBe(true);
224
- }
225
- }
226
- });
227
-
228
- test("glob result items carry expected fields", () => {
229
- const result = finder.glob("**/*.ts", { pageSize: 1 });
230
- expect(result.ok).toBe(true);
231
- if (result.ok && result.value.items.length > 0) {
232
- const item = result.value.items[0];
233
- expect(typeof item.relativePath).toBe("string");
234
- expect(typeof item.fileName).toBe("string");
235
- expect(item.relativePath.length).toBeGreaterThan(0);
236
- }
237
- });
238
-
239
- test("glob literal extension pattern (no leading **) still filters", () => {
240
- const result = finder.glob("*.ts", { pageSize: 100 });
241
- expect(result.ok).toBe(true);
242
- // Don't assert non-zero — depends on whether top-level .ts files exist.
243
- // Just assert all returned items match.
244
- if (result.ok) {
245
- for (const item of result.value.items) {
246
- expect(item.relativePath.endsWith(".ts")).toBe(true);
247
- }
248
- }
249
- });
250
-
251
- test("grep plain text returns matching lines", () => {
252
- const result = finder.grep("fff-core", {
253
- mode: "plain",
254
- });
255
- expect(result.ok).toBe(true);
256
-
257
- if (result.ok) {
258
- if (result.value.items.length > 0) {
259
- // Log sample match to verify content on Windows
260
- const first = result.value.items[0];
261
- const _normalizedPath = normalizePath(first.relativePath);
262
- }
263
-
264
- expect(result.value.totalMatched).toBeGreaterThan(0);
265
- expect(result.value.items.length).toBeGreaterThan(0);
266
-
267
- const first = result.value.items[0];
268
- expect(typeof first.relativePath).toBe("string");
269
- // Normalize path for cross-platform validation
270
- const normalizedFirstPath = normalizePath(first.relativePath);
271
- expect(normalizedFirstPath).toBeTruthy();
272
- expect(typeof first.lineNumber).toBe("number");
273
- expect(first.lineNumber).toBeGreaterThan(0);
274
- expect(typeof first.lineContent).toBe("string");
275
- expect(first.lineContent.toLowerCase()).toContain("fff-core");
276
- expect(Array.isArray(first.matchRanges)).toBe(true);
277
- expect(first.matchRanges.length).toBeGreaterThan(0);
278
-
279
- expect(typeof result.value.totalFilesSearched).toBe("number");
280
- expect(typeof result.value.totalFiles).toBe("number");
281
- expect(typeof result.value.filteredFileCount).toBe("number");
282
- } else {
283
- }
284
- });
285
-
286
- test("grep respects pageSize option", () => {
287
- // Cap to one match per file so pageSize bounds the total deterministically.
288
- const unbounded = finder.grep("import", {
289
- mode: "plain",
290
- maxMatchesPerFile: 1,
291
- });
292
- expect(unbounded.ok).toBe(true);
293
- if (!unbounded.ok) return;
294
- expect(unbounded.value.items.length).toBeGreaterThan(2);
295
-
296
- const limited = finder.grep("import", {
297
- mode: "plain",
298
- maxMatchesPerFile: 1,
299
- pageSize: 2,
300
- });
301
- expect(limited.ok).toBe(true);
302
- if (!limited.ok) return;
303
- expect(limited.value.items.length).toBeLessThanOrEqual(2);
304
- expect(limited.value.items.length).toBeLessThan(unbounded.value.items.length);
305
- expect(limited.value.nextCursor).not.toBeNull();
306
- });
307
-
308
- test("grep fuzzy mode returns results with scores", () => {
309
- // Intentional typo: "depdnency" instead of "dependency" to exercise fuzzy matching
310
- const result = finder.grep("depdnency", {
311
- mode: "fuzzy",
312
- });
313
- expect(result.ok).toBe(true);
314
-
315
- if (result.ok) {
316
- expect(result.value.totalMatched).toBeGreaterThan(0);
317
- expect(result.value.items.length).toBeGreaterThan(0);
318
-
319
- const first = result.value.items[0];
320
- expect(typeof first.relativePath).toBe("string");
321
- // Normalize path for cross-platform validation
322
- const normalizedFirstPath = normalizePath(first.relativePath);
323
- expect(normalizedFirstPath).toBeTruthy();
324
- expect(typeof first.lineNumber).toBe("number");
325
- expect(typeof first.lineContent).toBe("string");
326
- // Fuzzy mode should produce a fuzzyScore on each match
327
- expect(typeof first.fuzzyScore).toBe("number");
328
- }
329
- });
330
-
331
- test("healthCheck shows initialized state", () => {
332
- const result = finder.healthCheck();
333
- expect(result.ok).toBe(true);
334
-
335
- if (result.ok) {
336
- expect(result.value.filePicker.initialized).toBe(true);
337
- expect(result.value.filePicker.basePath).toBeDefined();
338
- // Normalize basePath for cross-platform comparison
339
- const normalizedBasePath = normalizePath(result.value.filePicker.basePath || "");
340
- const normalizedTestDir = normalizePath(testDir);
341
- expect(normalizedBasePath).toBe(normalizedTestDir);
342
- expect(typeof result.value.filePicker.indexedFiles).toBe("number");
343
- }
344
- });
345
-
346
- test("healthCheck detects git repository", () => {
347
- const result = finder.healthCheck(testDir);
348
- expect(result.ok).toBe(true);
349
-
350
- if (result.ok) {
351
- expect(result.value.git.available).toBe(true);
352
- expect(typeof result.value.git.repositoryFound).toBe("boolean");
353
- }
354
- });
355
-
356
- test("destroy and re-create works", () => {
357
- finder.destroy();
358
- expect(finder.isDestroyed).toBe(true);
359
-
360
- const result = FileFinder.create({ basePath: testDir });
361
- expect(result.ok).toBe(true);
362
- if (result.ok) {
363
- finder = result.value;
364
- }
365
- expect(finder.isDestroyed).toBe(false);
366
- });
367
-
368
- test("multiple instances can coexist", () => {
369
- const result2 = FileFinder.create({ basePath: testDir });
370
- expect(result2.ok).toBe(true);
371
-
372
- if (result2.ok) {
373
- const finder2 = result2.value;
374
-
375
- // Both should work independently
376
- const search1 = finder.fileSearch("Cargo");
377
- const search2 = finder2.fileSearch("Cargo");
378
-
379
- expect(search1.ok).toBe(true);
380
- expect(search2.ok).toBe(true);
381
-
382
- // Destroying one should not affect the other
383
- finder2.destroy();
384
-
385
- const search3 = finder.fileSearch("Cargo");
386
- expect(search3.ok).toBe(true);
387
- }
388
- });
389
- });
390
-
391
- describe("FileFinder - Directory Search", () => {
392
- let finder: FileFinder;
393
- const tmpDir = path.join(testDir, "__test_dirs__");
394
- const sep = path.sep;
395
-
396
- beforeAll(() => {
397
- fs.mkdirSync(path.join(tmpDir, "alpha", "nested"), { recursive: true });
398
- fs.mkdirSync(path.join(tmpDir, "beta"), { recursive: true });
399
- fs.writeFileSync(path.join(tmpDir, "alpha", "file.txt"), "x");
400
- fs.writeFileSync(path.join(tmpDir, "alpha", "nested", "deep.txt"), "x");
401
- fs.writeFileSync(path.join(tmpDir, "beta", "file.txt"), "x");
402
-
403
- const result = FileFinder.create({ basePath: testDir });
404
- expect(result.ok).toBe(true);
405
- if (result.ok) {
406
- finder = result.value;
407
- }
408
- finder.waitForScanBlocking(5000);
409
- });
410
-
411
- afterAll(() => {
412
- finder?.destroy();
413
- fs.rmSync(tmpDir, { recursive: true, force: true });
414
- });
415
-
416
- test("known directories are returned with correct paths", () => {
417
- const result = finder.directorySearch("__test_dirs__");
418
- expect(result.ok).toBe(true);
419
- if (!result.ok) return;
420
-
421
- const paths = result.value.items.map((i) => i.relativePath);
422
- expect(paths).toContain(`__test_dirs__${sep}alpha${sep}`);
423
- expect(paths).toContain(`__test_dirs__${sep}beta${sep}`);
424
- expect(paths).toContain(`__test_dirs__${sep}alpha${sep}nested${sep}`);
425
- });
426
-
427
- test("nested directory uses native separators and correct dirName", () => {
428
- const result = finder.directorySearch("nested");
429
- expect(result.ok).toBe(true);
430
- if (!result.ok) return;
431
-
432
- const nested = result.value.items.find((i) => i.relativePath.includes("nested"));
433
- expect(nested).toBeDefined();
434
- expect(nested!.relativePath).toBe(`__test_dirs__${sep}alpha${sep}nested${sep}`);
435
- expect(nested!.dirName).toBe(`nested${sep}`);
436
- });
437
- });
438
-
439
- describe("FileFinder - Error Handling", () => {
440
- test("search fails on destroyed instance", () => {
441
- const createResult = FileFinder.create({ basePath: testDir });
442
- expect(createResult.ok).toBe(true);
443
- if (!createResult.ok) return;
444
-
445
- const f = createResult.value;
446
- f.destroy();
447
-
448
- const result = f.fileSearch("test");
449
- expect(result.ok).toBe(false);
450
- if (!result.ok) {
451
- expect(result.error).toContain("destroyed");
452
- }
453
- });
454
-
455
- test("getScanProgress fails on destroyed instance", () => {
456
- const createResult = FileFinder.create({ basePath: testDir });
457
- expect(createResult.ok).toBe(true);
458
- if (!createResult.ok) return;
459
-
460
- const f = createResult.value;
461
- f.destroy();
462
-
463
- const result = f.getScanProgress();
464
- expect(result.ok).toBe(false);
465
- });
466
-
467
- test("create fails with invalid path", () => {
468
- // Use a cross-platform invalid path
469
- const invalidPath =
470
- process.platform === "win32"
471
- ? "C:\\nonexistent\\path\\that\\does\\not\\exist"
472
- : "/nonexistent/path/that/does/not/exist";
473
-
474
- const result = FileFinder.create({
475
- basePath: invalidPath,
476
- });
477
-
478
- expect(result.ok).toBe(false);
479
- if (!result.ok) {
480
- expect(result.error).toContain("Failed");
481
- }
482
- });
483
- });
484
-
485
- describe("Result Type Helpers", () => {
486
- test("ok helper creates success result", async () => {
487
- const { ok } = await import("./fff-api");
488
- const result = ok(42);
489
- expect(result.ok).toBe(true);
490
- if (result.ok) {
491
- expect(result.value).toBe(42);
492
- }
493
- });
494
-
495
- test("err helper creates error result", async () => {
496
- const { err } = await import("./fff-api");
497
- const result = err<number>("something went wrong");
498
- expect(result.ok).toBe(false);
499
- if (!result.ok) {
500
- expect(result.error).toBe("something went wrong");
501
- }
502
- });
503
- });