@ff-labs/fff-bun 0.9.4-nightly.8092cfa → 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 +20 -0
- package/package.json +10 -10
- package/src/embed.d.ts +17 -0
- package/src/embedded.ts +53 -0
- package/src/ffi.ts +67 -16
- package/src/git-lifecycle.test.ts +0 -367
- package/src/index.test.ts +0 -503
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
|
|
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
|
|
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
|
|
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
|
|
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";
|
package/src/embedded.ts
ADDED
|
@@ -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
|
|
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) =>
|
|
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 === "")
|
|
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(
|
|
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(
|
|
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: {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
});
|