chore: import upstream snapshot with attribution
Python CI / Python bindings (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 0s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 1s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 0s
Rust CI / cargo clippy (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Failing after 1s
Spelling / Spell Check with Typos (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 0s
Rust CI / cargo fmt (push) Failing after 0s
Stylua / Check lua files using Stylua (push) Failing after 1s
Lua CI / lua-language-server type check (push) Failing after 12m29s
e2e Tests / e2e (alpine-musl) (push) Successful in 57m31s
e2e Tests / e2e (macos-latest) (push) Has been cancelled
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Rust CI / Test (windows-latest) (push) Has been cancelled
e2e Tests / e2e (windows-latest) (push) Has been cancelled
Nix CI / check (push) Has been cancelled
Python CI / Python bindings (macos-latest) (push) Has been cancelled
Python CI / Python bindings (windows-latest) (push) Has been cancelled
Build & Publish / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Has been cancelled
Build & Publish / Release (push) Has been cancelled
Build & Publish / Publish Python wheels to PyPI (push) Has been cancelled
Build & Publish / Publish Rust crates (push) Has been cancelled
Build & Publish / Publish npm packages (push) Has been cancelled
Rust CI / Fuzz Tests (windows-latest) (push) Has been cancelled
Rust CI / Test (macos-latest) (push) Has been cancelled
Rust CI / Fuzz Tests (macos-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:27:16 +08:00
commit d5f207424b
328 changed files with 87463 additions and 0 deletions
@@ -0,0 +1,18 @@
{
"name": "@ff-labs/fff-bin-darwin-arm64",
"version": "0.0.0",
"description": "fff native binary for macOS ARM64 (Apple Silicon)",
"os": ["darwin"],
"cpu": ["arm64"],
"main": "libfff_c.dylib",
"files": ["libfff_c.dylib"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-darwin-arm64"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@ff-labs/fff-bin-darwin-x64",
"version": "0.0.0",
"description": "fff native binary for macOS x64 (Intel)",
"os": ["darwin"],
"cpu": ["x64"],
"main": "libfff_c.dylib",
"files": ["libfff_c.dylib"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-darwin-x64"
}
}
@@ -0,0 +1,19 @@
{
"name": "@ff-labs/fff-bin-linux-arm64-gnu",
"version": "0.0.0",
"description": "fff native binary for Linux ARM64 (glibc)",
"os": ["linux"],
"cpu": ["arm64"],
"main": "libfff_c.so",
"files": ["libfff_c.so"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-linux-arm64-gnu"
},
"libc": ["glibc"]
}
@@ -0,0 +1,19 @@
{
"name": "@ff-labs/fff-bin-linux-arm64-musl",
"version": "0.0.0",
"description": "fff native binary for Linux ARM64 (musl)",
"os": ["linux"],
"cpu": ["arm64"],
"main": "libfff_c.so",
"files": ["libfff_c.so"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-linux-arm64-musl"
},
"libc": ["musl"]
}
@@ -0,0 +1,19 @@
{
"name": "@ff-labs/fff-bin-linux-x64-gnu",
"version": "0.0.0",
"description": "fff native binary for Linux x64 (glibc)",
"os": ["linux"],
"cpu": ["x64"],
"main": "libfff_c.so",
"files": ["libfff_c.so"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-linux-x64-gnu"
},
"libc": ["glibc"]
}
@@ -0,0 +1,19 @@
{
"name": "@ff-labs/fff-bin-linux-x64-musl",
"version": "0.0.0",
"description": "fff native binary for Linux x64 (musl)",
"os": ["linux"],
"cpu": ["x64"],
"main": "libfff_c.so",
"files": ["libfff_c.so"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-linux-x64-musl"
},
"libc": ["musl"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@ff-labs/fff-bin-win32-arm64",
"version": "0.0.0",
"description": "fff native binary for Windows ARM64",
"os": ["win32"],
"cpu": ["arm64"],
"main": "fff_c.dll",
"files": ["fff_c.dll"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-win32-arm64"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@ff-labs/fff-bin-win32-x64",
"version": "0.0.0",
"description": "fff native binary for Windows x64",
"os": ["win32"],
"cpu": ["x64"],
"main": "fff_c.dll",
"files": ["fff_c.dll"],
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-bin-win32-x64"
}
}
+2
View File
@@ -0,0 +1,2 @@
examples/
test/
+148
View File
@@ -0,0 +1,148 @@
# fff - Fast File Finder
High-performance fuzzy file finder for Bun, powered by Rust. Extremely fast live file, content, and directory search with a typo-resistant algorithm. As well as regex, plain-text, multi-occurrence and typo-resistant content search.
Comes with built-in git status support, frecency access tracking, and a real-time file watcher, content indexing and many more! Designed for LLM agent tools that search through codebases or agentic RAG document search.
Faster than ripgrep & fzf on any workflow that runs more than once per process.
## Installation
```bash
bun add @ff-labs/fff-bun
```
The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bin-darwin-arm64`, `@ff-labs/fff-bin-linux-x64-gnu`)
### Supported Platforms
| Platform | Architecture | Package |
| -------- | --------------------- | ----------------------------------- |
| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bin-darwin-arm64` |
| macOS | x64 (Intel) | `@ff-labs/fff-bin-darwin-x64` |
| Linux | x64 (glibc) | `@ff-labs/fff-bin-linux-x64-gnu` |
| Linux | ARM64 (glibc) | `@ff-labs/fff-bin-linux-arm64-gnu` |
| Linux | x64 (musl) | `@ff-labs/fff-bin-linux-x64-musl` |
| Linux | ARM64 (musl) | `@ff-labs/fff-bin-linux-arm64-musl` |
| Windows | x64 | `@ff-labs/fff-bin-win32-x64` |
| Windows | ARM64 | `@ff-labs/fff-bin-win32-arm64` |
If the platform package isn't available, the postinstall script will attempt to download from GitHub releases as a fallback.
### Standalone executables (`bun build --compile`)
`@ff-labs/fff-bun` embeds the native library into single-file executables built
with `bun build --compile`. macOS and Windows work with no extra flags:
```bash
bun build --compile ./app.ts --outfile myapp
```
On **Linux** the C library's libc (glibc vs musl) can't be detected at build
time, so you must pass it as a build constant for the lib to embed:
```bash
bun build --compile --define FFF_LIBC='"gnu"' ./app.ts --outfile myapp # glibc
bun build --compile --define FFF_LIBC='"musl"' ./app.ts --outfile myapp # musl / Alpine
```
Without the define on Linux the library is resolved at runtime instead of being
embedded, which works under `bun run` but not in a standalone binary.
## Quick Start
Each `FileFinder` instance owns an independent native index. Create one, wait
for the initial scan, then run as many searches as you like.
```typescript
import { FileFinder } from "@ff-labs/fff-bun";
// Create an instance bound to a directory
const created = FileFinder.create({ basePath: "/path/to/project" });
if (!created.ok) throw new Error(created.error);
const finder = created.value;
// Wait for the initial scan (non-blocking)
await finder.waitForScan(5000);
// 1. Fuzzy file search (typo resistant)
const files = finder.fileSearch("typescropt.ts", { pageSize: 10 });
if (files.ok) {
for (const item of files.value.items) {
console.log(item.relativePath, item.gitStatus);
}
}
// 2. Glob filter — no fuzzy matching, 100% compatible with npm `glob`
const globbed = finder.glob("src/**/*.ts");
if (globbed.ok) console.log(`${globbed.value.totalMatched} TypeScript files`);
// 3. Content search (live grep) with pagination
const grep = finder.grep("TODO", { mode: "plain", pageSize: 20 });
if (grep.ok) {
for (const m of grep.value.items) {
console.log(`${m.relativePath}:${m.lineNumber}: ${m.lineContent}`);
}
}
// 4. Directory search based on the query (typo resistant)
const dirs = finder.directorySearch("components");
if (dirs.ok) console.log(dirs.value.items.map((d) => d.relativePath));
// Free the resources when you don't need a file picker anymore
finder.destroy();
```
## API Reference
Verify the latest API in the local interface at [`./src/fff-api.ts`](./src/fff-api.ts). Every field and type is documented.
### Result Types
All methods return a `Result<T>` type for explicit error handling:
```typescript
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
const result = finder.fileSearch("foo");
if (result.ok) {
// result.value is SearchResult
} else {
// result.error is string error message
}
```
This SDK calls a native compiled library for your platform at runtime. This is generally safe — fff is battle-tested and stable, and written in a memory-safe language — but there is a class of errors that can't be caught at the Bun/Node level. If you hit one, please report an issue!
## Building from Source
If prebuilt binaries aren't available for your platform:
```bash
# Clone the repository
git clone https://github.com/dmtrKovalenko/fff.nvim
cd fff.nvim
# Build the C library
cargo build --release -p fff-c
# The binary will be at target/release/libfff_c.{so,dylib,dll}
```
## CLI examples
```bash
# Download binary manually (fallback if npm package unavailable)
bunx fff download [tag]
# Show platform info and binary location
bunx fff info
```
## License
MIT
+61
View File
@@ -0,0 +1,61 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "fff",
"devDependencies": {
"@types/bun": "^1.3.8",
"typescript": "^5.0.0",
},
"optionalDependencies": {
"@ff-labs/fff-bun-darwin-arm64": "0.0.0",
"@ff-labs/fff-bun-darwin-x64": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bun-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bun-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bun-win32-arm64": "0.0.0",
"@ff-labs/fff-bun-win32-x64": "0.0.0",
},
"peerDependencies": {
"bun": ">=1.0.0",
},
},
},
"packages": {
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-YiLxfsPzQqaVvT2a+nxH9do0YfUjrlxF3tKP0b1DDgvfgCcVKGsrQH3Wa82qHgL4dnT8h2bqi94JxXESEuPmcA=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XbhsA2XAFzvFr0vPSV6SNqGxab4xHKdPmVTLqoSHAx9tffrSq/012BDptOskulwnD+YNsrJUx2D2Ve1xvfgGcg=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-VaNQTu0Up4gnwZLQ6/Hmho6jAlLxTQ1PwxEth8EsXHf82FOXXPV5OCQ6KC9mmmocjKlmWFaIGebThrOy8DUo4g=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-t8uimCVBTw5f9K2QTZE5wN6UOrFETNrh/Xr7qtXT9nAOzaOnIFvYA+HcHbGfi31fRlCVfTxqm/EiCwJ1gEw9YQ=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-oQyAW3+ugulvXTZ+XYeUMmNPR94sJeMokfHQoKwPvVwhVkgRuMhcLGV2ZesHCADVu30Oz2MFXbgdC8x4/o9dRg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-nZ12g22cy7pEOBwAxz2tp0wVqekaCn9QRKuGTHqOdLlyAqR4SCdErDvDhUWd51bIyHTQoCmj72TegGTgG0WNPw=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-4ZjIUgCxEyKwcKXideB5sX0KJpnHTZtu778w73VNq2uNH2fNpMZv98+DBgJyQ9OfFoRhmKn1bmLmSefvnHzI9w=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-3FXQgtYFsT0YOmAdMcJn56pLM5kzSl6y942rJJIl5l2KummB9Ea3J/vMJMzQk7NCAGhleZGWU/pJSS/uXKGa7w=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/d6vAmgKvkoYlsGPsRPlPmOK1slPis/F40UG02pYwypTH0wmY0smgzdFqR4YmryxFh17XrW1kITv+U99Oajk9Q=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-a/+hSrrDpMD7THyXvE2KJy1skxzAD0cnW4K1WjuI/91VqsphjNzvf5t/ZgxEVL4wb6f+hKrSJ5J3aH47zPr61g=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
"bun": ["bun@1.3.9", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.9", "@oven/bun-darwin-x64": "1.3.9", "@oven/bun-darwin-x64-baseline": "1.3.9", "@oven/bun-linux-aarch64": "1.3.9", "@oven/bun-linux-aarch64-musl": "1.3.9", "@oven/bun-linux-x64": "1.3.9", "@oven/bun-linux-x64-baseline": "1.3.9", "@oven/bun-linux-x64-musl": "1.3.9", "@oven/bun-linux-x64-musl-baseline": "1.3.9", "@oven/bun-windows-x64": "1.3.9", "@oven/bun-windows-x64-baseline": "1.3.9" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bun
/**
* Glob benchmark: fff.glob vs Bun.Glob vs npm `glob`.
*
* Each engine is asked to enumerate files in a directory matching the same
* pattern. We measure wall-clock time + result count. fff scans + indexes
* once on init; the subsequent glob call is a filter over the in-memory
* index — that's what we time.
*
* Usage:
* bun examples/glob-bench.ts [dir] [pattern] [iterations]
*
* dir default: cwd
* pattern default: "**\/*.ts"
* iterations default: 5 (each engine runs N times, best+median reported)
*
* Install npm glob first:
* bun add glob
*/
import { performance } from "node:perf_hooks";
import { resolve } from "node:path";
import { Glob as BunGlob } from "bun";
import { FileFinder } from "../src/index";
// npm glob — optional. Skip silently if not installed.
let npmGlob: ((pattern: string, opts: { cwd: string }) => Promise<string[]>) | null =
null;
try {
const mod: {
glob: (pattern: string, opts: { cwd: string }) => Promise<string[]>;
} =
// @ts-ignore - optional peer; resolved at runtime, may be absent
await import("glob");
npmGlob = mod.glob;
} catch {
console.warn("npm `glob` not installed — skipping. Run: bun add glob");
}
const dir = resolve(process.argv[2] ?? process.cwd());
const pattern = process.argv[3] ?? "**/lua/**/*.lua";
const iterations = Number(process.argv[4] ?? 5);
console.log(`dir: ${dir}`);
console.log(`pattern: ${pattern}`);
console.log(`iterations: ${iterations}\n`);
interface Sample {
ms: number;
count: number;
}
function summarize(label: string, samples: Sample[]): void {
if (samples.length === 0) {
console.log(`${label.padEnd(16)} skipped`);
return;
}
const sorted = [...samples].sort((a, b) => a.ms - b.ms);
const best = sorted[0]!;
const median = sorted[Math.floor(sorted.length / 2)]!;
const worst = sorted[sorted.length - 1]!;
const counts = new Set(samples.map((s) => s.count));
const countStr = counts.size === 1 ? `${best.count}` : `[${[...counts].join(", ")}]`;
console.log(
`${label.padEnd(16)} best=${best.ms.toFixed(2)}ms median=${median.ms.toFixed(2)}ms worst=${worst.ms.toFixed(2)}ms count=${countStr}`,
);
}
async function bench<T>(fn: () => Promise<T> | T): Promise<{ ms: number; result: T }> {
const start = performance.now();
const result = await fn();
return { ms: performance.now() - start, result };
}
// ---------------------------------------------------------------------------
// fff: init + warm scan, then time only the .glob() call. Init cost is
// reported separately because it's amortized across many subsequent calls.
// ---------------------------------------------------------------------------
const fffInit = await bench(() => {
const result = FileFinder.create({
basePath: dir,
disableMmapCache: true,
disableContentIndexing: true,
disableWatch: true,
});
if (!result.ok) throw new Error(result.error);
return result.value;
});
const finder = fffInit.result;
// Wait until initial scan done so the first .glob() doesn't see a partial
// index. Returns true = completed, false = timed out.
const scanReady = finder.waitForScanBlocking(30_000);
if (!scanReady.ok || !scanReady.value) {
console.error("fff: initial scan did not finish in 30s — exiting");
process.exit(1);
}
console.log(`fff init+scan: ${fffInit.ms.toFixed(2)}ms\n`);
const fffSamples: Sample[] = [];
for (let i = 0; i < iterations; i++) {
const r = await bench(() => {
const out = finder.glob(pattern, { pageSize: 100 });
if (!out.ok) throw new Error(out.error);
return out.value;
});
fffSamples.push({ ms: r.ms, count: r.result.items.length });
}
// ---------------------------------------------------------------------------
// Bun.Glob — sync iterator, returns relative paths.
// ---------------------------------------------------------------------------
const bunSamples: Sample[] = [];
for (let i = 0; i < iterations; i++) {
const r = await bench(() => {
const g = new BunGlob(pattern);
let count = 0;
for (const _ of g.scanSync({ cwd: dir })) count++;
return count;
});
bunSamples.push({ ms: r.ms, count: r.result });
}
// ---------------------------------------------------------------------------
// npm glob — async, returns absolute or relative paths depending on opts.
// ---------------------------------------------------------------------------
const npmSamples: Sample[] = [];
if (npmGlob) {
for (let i = 0; i < iterations; i++) {
const r = await bench(() => npmGlob!(pattern, { cwd: dir }));
npmSamples.push({ ms: r.ms, count: r.result.length });
}
}
console.log("results:");
summarize("fff.glob", fffSamples);
summarize("Bun.Glob", bunSamples);
summarize("npm glob", npmSamples);
// Sanity: counts should be in the same ballpark. They won't match exactly
// because indexing rules differ (fff respects gitignore + skips binaries by
// default; Bun.Glob and npm glob do not).
const counts = {
fff: fffSamples[0]?.count ?? 0,
bun: bunSamples[0]?.count ?? 0,
npm: npmSamples[0]?.count ?? 0,
};
console.log(
`\nNote: fff respects gitignore + skips binaries; Bun.Glob and npm glob walk the raw filesystem. Count differences are expected.`,
);
console.log(`raw counts: fff=${counts.fff} bun=${counts.bun} npm=${counts.npm}`);
finder.destroy();
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env bun
/**
* Interactive live grep demo
*
* Usage:
* bun examples/grep.ts [directory] [--mode=plain|regex|fuzzy]
*
* Indexes the specified directory (or cwd) and provides an interactive
* content search prompt with match highlighting.
*/
import { FileFinder } from "../src/index";
import type { GrepMode } from "../src/types";
import * as readline from "node:readline";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
const BLUE = "\x1b[34m";
const CYAN = "\x1b[36m";
const RED = "\x1b[31m";
const BG_YELLOW = "\x1b[43m";
const BLACK = "\x1b[30m";
function formatGitStatus(status: string): string {
switch (status) {
case "modified":
return `${YELLOW}M${RESET}`;
case "untracked":
return `${GREEN}?${RESET}`;
case "added":
return `${GREEN}A${RESET}`;
case "deleted":
return `${RED}D${RESET}`;
case "renamed":
return `${BLUE}R${RESET}`;
case "clean":
case "current":
return `${DIM} ${RESET}`;
default:
return `${DIM}${status.charAt(0)}${RESET}`;
}
}
/**
* Highlight match ranges within line content using ANSI escape codes.
* The match_ranges are byte offsets into line_content.
*/
function highlightLine(content: string, ranges: [number, number][]): string {
if (ranges.length === 0) return content;
// Convert the string to a buffer to work with byte offsets
const buf = Buffer.from(content, "utf-8");
const parts: string[] = [];
let lastEnd = 0;
for (const [start, end] of ranges) {
// Clamp to valid range
const s = Math.max(0, Math.min(start, buf.length));
const e = Math.max(s, Math.min(end, buf.length));
if (s > lastEnd) {
parts.push(buf.subarray(lastEnd, s).toString("utf-8"));
}
parts.push(`${BG_YELLOW}${BLACK}${buf.subarray(s, e).toString("utf-8")}${RESET}`);
lastEnd = e;
}
if (lastEnd < buf.length) {
parts.push(buf.subarray(lastEnd).toString("utf-8"));
}
return parts.join("");
}
function parseArgs(): { directory: string; mode: GrepMode } {
let directory = process.cwd();
let mode: GrepMode = "plain";
for (const arg of process.argv.slice(2)) {
if (arg.startsWith("--mode=")) {
const m = arg.slice(7);
if (m === "plain" || m === "regex" || m === "fuzzy") {
mode = m;
} else {
console.error(`Unknown mode: ${m}. Use plain, regex, or fuzzy.`);
process.exit(1);
}
} else if (!arg.startsWith("-")) {
directory = arg;
}
}
return { directory, mode };
}
async function main() {
const { directory, mode } = parseArgs();
console.log(`${BOLD}${CYAN}fff - Live Grep Demo${RESET}`);
console.log(`${DIM}Mode: ${mode}${RESET}\n`);
if (!FileFinder.isAvailable()) {
console.error(`${RED}Error: Native library not found.${RESET}`);
console.error("Build with: cargo build --release -p fff-c");
process.exit(1);
}
console.log(`${DIM}Initializing index for: ${directory}${RESET}`);
const createResult = FileFinder.create({
basePath: directory,
});
if (!createResult.ok) {
console.error(`${RED}Init failed: ${createResult.error}${RESET}`);
process.exit(1);
}
const finder = createResult.value;
// Wait for scan
process.stdout.write(`${DIM}Scanning files...${RESET}`);
const startTime = Date.now();
while (finder.isScanning()) {
const progress = finder.getScanProgress();
if (progress.ok) {
process.stdout.write(
`\r${DIM}Scanning files... ${progress.value.scannedFilesCount}${RESET} `,
);
}
await new Promise((r) => setTimeout(r, 50));
}
const scanTime = Date.now() - startTime;
const finalProgress = finder.getScanProgress();
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
const warmupDone = finalProgress.ok ? finalProgress.value.isWarmupComplete : false;
console.log(
`\r${GREEN}${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms`,
);
console.log(
` Content index: ${warmupDone ? `${GREEN}ready${RESET}` : `${YELLOW}building in background...${RESET}`}\n`,
);
console.log(
`${BOLD}Enter a search pattern${RESET} (or 'q' to quit, ':mode plain|regex|fuzzy' to switch):\n`,
);
console.log(
`${DIM}Tip: prefix with *.ext to filter by extension, e.g. "*.ts useState"${RESET}\n`,
);
let currentMode = mode;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = () => {
const modeLabel =
currentMode === "plain" ? "txt" : currentMode === "regex" ? "re" : "fzy";
const prog = finder.getScanProgress();
const indexed = prog.ok && prog.value.isWarmupComplete;
const indexLabel = indexed ? `${GREEN}idx${RESET}` : `${YELLOW}no-idx${RESET}`;
rl.question(`${CYAN}grep[${modeLabel}|${indexLabel}]>${RESET} `, (query) => {
if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
console.log(`\n${DIM}Goodbye!${RESET}`);
finder.destroy();
rl.close();
process.exit(0);
}
// Handle mode switching
if (query.startsWith(":mode ")) {
const newMode = query.slice(6).trim();
if (newMode === "plain" || newMode === "regex" || newMode === "fuzzy") {
currentMode = newMode;
console.log(`${DIM}Switched to ${currentMode} mode${RESET}\n`);
} else {
console.log(
`${RED}Unknown mode: ${newMode}. Use plain, regex, or fuzzy.${RESET}\n`,
);
}
prompt();
return;
}
if (query.trim() === "") {
prompt();
return;
}
const searchStart = Date.now();
const result = finder.grep(query, {
mode: currentMode,
pageLimit: 30,
timeBudgetMs: 5000,
});
const searchTime = Date.now() - searchStart;
if (!result.ok) {
console.log(`${RED}Grep error: ${result.error}${RESET}\n`);
prompt();
return;
}
const {
items,
totalMatched,
totalFilesSearched,
totalFiles: _,
filteredFileCount,
nextCursor,
regexFallbackError,
} = result.value;
console.log();
if (regexFallbackError) {
console.log(
`${YELLOW}Regex error: ${regexFallbackError} (fell back to literal match)${RESET}`,
);
}
console.log(
`${DIM}${BOLD}${totalMatched}${RESET}${DIM} matches across ${totalFilesSearched}/${filteredFileCount} files (${searchTime}ms)${RESET}`,
);
console.log();
if (items.length === 0) {
console.log(`${DIM}No matches found.${RESET}\n`);
prompt();
return;
}
// Group matches by file for display
let lastFile = "";
for (const match of items) {
if (match.relativePath !== lastFile) {
lastFile = match.relativePath;
const git = formatGitStatus(match.gitStatus);
console.log(`${BOLD}${BLUE}${match.relativePath}${RESET} ${git}`);
}
const lineNum = String(match.lineNumber).padStart(4);
const highlighted = highlightLine(match.lineContent, match.matchRanges);
let suffix = "";
if (match.fuzzyScore !== undefined) {
suffix = ` ${DIM}(score: ${match.fuzzyScore})${RESET}`;
}
console.log(`${DIM}${lineNum}:${RESET} ${highlighted}${suffix}`);
}
if (nextCursor) {
console.log(`\n${DIM}... more results available${RESET}`);
}
console.log();
prompt();
});
};
prompt();
}
main().catch((err) => {
console.error(`${RED}Fatal error: ${err.message}${RESET}`);
process.exit(1);
});
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env bun
/**
* Interactive file finder demo
*
* Usage:
* bunx fff-demo [directory]
* bun examples/search.ts [directory]
*
* Indexes the specified directory (or cwd) and provides an interactive
* search prompt with detailed metadata about results.
*
* Modes:
* :files - fuzzy file search (default)
* :dirs - fuzzy directory search
* :mixed - mixed file + directory search
*/
import { FileFinder } from "../src/index";
import type { DirItem, FileItem, MixedItem, Score } from "../src/index";
import * as readline from "node:readline";
import { join } from "node:path";
import { homedir } from "node:os";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
const BLUE = "\x1b[34m";
const CYAN = "\x1b[36m";
const MAGENTA = "\x1b[35m";
const RED = "\x1b[31m";
type SearchMode = "files" | "dirs" | "mixed";
function formatGitStatus(status: string): string {
switch (status) {
case "modified":
return `${YELLOW}M${RESET}`;
case "untracked":
return `${GREEN}?${RESET}`;
case "added":
return `${GREEN}A${RESET}`;
case "deleted":
return `${RED}D${RESET}`;
case "renamed":
return `${BLUE}R${RESET}`;
case "clean":
case "current":
return `${DIM} ${RESET}`;
default:
return `${DIM}${status.charAt(0)}${RESET}`;
}
}
/** Pad a plain string first, then wrap with ANSI color. */
function padColor(
value: string,
width: number,
color: string,
align: "left" | "right" = "right",
): string {
const padded = align === "right" ? value.padStart(width) : value.padEnd(width);
return `${color}${padded}${RESET}`;
}
function scoreColor(score: number): string {
if (score >= 100) return GREEN;
if (score >= 50) return YELLOW;
return DIM;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
return `${(bytes / 1024 / 1024).toFixed(1)}M`;
}
function formatTime(unixSeconds: number): string {
if (unixSeconds === 0) return "unknown";
const date = new Date(unixSeconds * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function formatModeTag(mode: SearchMode): string {
switch (mode) {
case "files":
return `${CYAN}files${RESET}`;
case "dirs":
return `${MAGENTA}dirs${RESET}`;
case "mixed":
return `${YELLOW}mixed${RESET}`;
}
}
function printScoreBreakdown(score: Score, indent: string) {
const breakdown: string[] = [];
if (score.baseScore > 0) breakdown.push(`base:${score.baseScore}`);
if (score.filenameBonus > 0) breakdown.push(`filename:+${score.filenameBonus}`);
if (score.frecencyBoost > 0) breakdown.push(`frecency:+${score.frecencyBoost}`);
if (score.comboMatchBoost > 0) breakdown.push(`combo:+${score.comboMatchBoost}`);
if (score.distancePenalty < 0) breakdown.push(`distance:${score.distancePenalty}`);
if (score.exactMatch) breakdown.push(`${GREEN}exact${RESET}`);
if (score.matchType) breakdown.push(`[${score.matchType}]`);
if (breakdown.length > 0) {
console.log(`${DIM}${indent}└─ ${breakdown.join(", ")}${RESET}`);
}
}
function printFileResult(item: FileItem, score: Score, showBreakdown: boolean) {
const git = formatGitStatus(item.gitStatus);
const sc = padColor(String(score.total), 5, scoreColor(score.total));
const size = formatSize(item.size).padStart(6);
const modified = formatTime(item.modified).padEnd(10);
console.log(` ${git}${sc}${size}${modified}${item.relativePath}`);
if (showBreakdown && score.total > 0) {
printScoreBreakdown(score, " │ │ │ │ ");
}
}
function printDirResult(item: DirItem, score: Score, showBreakdown: boolean) {
const sc = padColor(String(score.total), 5, scoreColor(score.total));
const frecency = (
item.maxAccessFrecency > 0 ? `f:${item.maxAccessFrecency}` : "-"
).padStart(6);
console.log(
` ${MAGENTA}D${RESET}${sc}${frecency}${"".padEnd(10)}${MAGENTA}${item.relativePath}${RESET}`,
);
if (showBreakdown && score.total > 0) {
printScoreBreakdown(score, " │ │ │ │ ");
}
}
function printMixedResult(mixed: MixedItem, score: Score, showBreakdown: boolean) {
if (mixed.type === "file") {
printFileResult(mixed.item, score, showBreakdown);
} else {
printDirResult(mixed.item, score, showBreakdown);
}
}
async function main() {
const targetDir = process.argv[2] || process.cwd();
console.log(`${BOLD}${CYAN}fff - Fast File Finder Demo${RESET}\n`);
// Check library availability
if (!FileFinder.isAvailable()) {
console.error(`${RED}Error: Native library not found.${RESET}`);
console.error("Build with: cargo build --release -p fff-c");
process.exit(1);
}
// Use the same frecency + history databases as the Neovim plugin
// so the demo benefits from real access history.
const nvimCache = process.env.XDG_CACHE_HOME || join(homedir(), ".cache", "nvim");
const nvimData =
process.env.XDG_DATA_HOME || join(homedir(), ".local", "share", "nvim");
const frecencyDbPath = join(nvimCache, "fff_nvim");
const historyDbPath = join(nvimData, "fff_queries");
console.log(`${DIM}Initializing index for: ${targetDir}${RESET}`);
console.log(`${DIM}Frecency DB: ${frecencyDbPath}${RESET}`);
const createResult = FileFinder.create({
basePath: targetDir,
frecencyDbPath,
historyDbPath,
useUnsafeNoLock: true,
});
if (!createResult.ok) {
console.error(`${RED}Init failed: ${createResult.error}${RESET}`);
process.exit(1);
}
const finder = createResult.value;
// Wait for scan with progress
process.stdout.write(`${DIM}Scanning files...${RESET}`);
const startTime = Date.now();
let lastCount = 0;
while (finder.isScanning()) {
const progress = finder.getScanProgress();
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
lastCount = progress.value.scannedFilesCount;
process.stdout.write(`\r${DIM}Scanning files... ${lastCount}${RESET} `);
}
await new Promise((r) => setTimeout(r, 50));
}
const scanTime = Date.now() - startTime;
const finalProgress = finder.getScanProgress();
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
console.log(
`\r${GREEN}${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`,
);
// Show index info
const health = finder.healthCheck();
if (health.ok) {
console.log(`${DIM}Version:${RESET} ${health.value.version}`);
console.log(`${DIM}Base path:${RESET} ${health.value.filePicker.basePath}`);
if (health.value.git.repositoryFound) {
console.log(`${DIM}Git root:${RESET} ${health.value.git.workdir}`);
}
}
let mode: SearchMode = "files";
// Interactive search loop
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log(`\n${BOLD}Commands:${RESET}`);
console.log(` ${DIM}:files${RESET} - file search mode (default)`);
console.log(` ${DIM}:dirs${RESET} - directory search mode`);
console.log(` ${DIM}:mixed${RESET} - mixed files + directories mode`);
console.log(` ${DIM}q${RESET} - quit`);
console.log();
const prompt = () => {
rl.question(`${CYAN}[${formatModeTag(mode)}]>${RESET} `, (query) => {
const trimmed = query.trim();
if (trimmed.toLowerCase() === "q" || trimmed.toLowerCase() === "quit") {
console.log(`\n${DIM}Goodbye!${RESET}`);
finder.destroy();
rl.close();
process.exit(0);
}
// Mode switching commands
if (trimmed === ":files") {
mode = "files";
console.log(`${GREEN}Switched to file search mode${RESET}\n`);
prompt();
return;
}
if (trimmed === ":dirs") {
mode = "dirs";
console.log(`${MAGENTA}Switched to directory search mode${RESET}\n`);
prompt();
return;
}
if (trimmed === ":mixed") {
mode = "mixed";
console.log(`${YELLOW}Switched to mixed search mode${RESET}\n`);
prompt();
return;
}
const searchStart = Date.now();
if (mode === "files") {
const result = finder.fileSearch(trimmed, { pageSize: 15 });
const searchTime = Date.now() - searchStart;
if (!result.ok) {
console.log(`${RED}Search error: ${result.error}${RESET}\n`);
prompt();
return;
}
const { items, scores, totalMatched, totalFiles } = result.value;
console.log();
console.log(
`${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalFiles} files (${searchTime}ms)${RESET}`,
);
console.log();
if (items.length === 0) {
console.log(`${DIM}No matches found.${RESET}\n`);
prompt();
return;
}
console.log(`${DIM} Git │ Score │ Size │ Modified │ Path${RESET}`);
for (let i = 0; i < items.length; i++) {
printFileResult(items[i], scores[i], i < 3);
}
if (totalMatched > items.length) {
console.log(
`${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`,
);
}
} else if (mode === "dirs") {
const result = finder.directorySearch(trimmed, { pageSize: 15 });
const searchTime = Date.now() - searchStart;
if (!result.ok) {
console.log(`${RED}Search error: ${result.error}${RESET}\n`);
prompt();
return;
}
const { items, scores, totalMatched, totalDirs } = result.value;
console.log();
console.log(
`${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalDirs} directories (${searchTime}ms)${RESET}`,
);
console.log();
if (items.length === 0) {
console.log(`${DIM}No matches found.${RESET}\n`);
prompt();
return;
}
console.log(`${DIM} Dir │ Score │ Frecnc │ │ Path${RESET}`);
for (let i = 0; i < items.length; i++) {
printDirResult(items[i], scores[i], i < 3);
}
if (totalMatched > items.length) {
console.log(
`${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`,
);
}
} else {
// mixed mode
const result = finder.mixedSearch(trimmed, { pageSize: 20 });
const searchTime = Date.now() - searchStart;
if (!result.ok) {
console.log(`${RED}Search error: ${result.error}${RESET}\n`);
prompt();
return;
}
const { items, scores, totalMatched, totalFiles, totalDirs } = result.value;
console.log();
console.log(
`${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches (${totalFiles} files, ${totalDirs} dirs) (${searchTime}ms)${RESET}`,
);
console.log();
if (items.length === 0) {
console.log(`${DIM}No matches found.${RESET}\n`);
prompt();
return;
}
console.log(`${DIM} Type │ Score │ Info │ Modified │ Path${RESET}`);
for (let i = 0; i < items.length; i++) {
printMixedResult(items[i], scores[i], i < 5);
}
if (totalMatched > items.length) {
console.log(
`${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`,
);
}
}
console.log();
prompt();
});
};
prompt();
}
main().catch((err) => {
console.error(`${RED}Fatal error: ${err.message}${RESET}`);
process.exit(1);
});
+74
View File
@@ -0,0 +1,74 @@
{
"name": "@ff-labs/fff-bun",
"version": "0.1.37",
"private": false,
"description": "High-performance fuzzy file finder for Bun - perfect for LLM agent tools",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"files": [
"src",
"examples"
],
"scripts": {
"test": "bun test test/",
"typecheck": "tsc --noEmit",
"demo": "bun ./examples/search.ts"
},
"engines": {
"bun": ">=1.0.0"
},
"os": [
"darwin",
"linux",
"win32"
],
"cpu": [
"x64",
"arm64"
],
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff"
},
"keywords": [
"file-finder",
"fuzzy-search",
"bun",
"ffi",
"llm-tools",
"agent-tools",
"fast",
"rust"
],
"author": "Dmitry Kovalenko",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/dmtrKovalenko/fff/issues"
},
"homepage": "https://github.com/dmtrKovalenko/fff#readme",
"optionalDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.0.0",
"@ff-labs/fff-bin-darwin-x64": "0.0.0",
"@ff-labs/fff-bin-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bin-win32-x64": "0.0.0",
"@ff-labs/fff-bin-win32-arm64": "0.0.0"
},
"devDependencies": {
"@types/bun": "^1.3.8",
"typescript": "^5.0.0"
}
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Binary resolution utilities for fff
*
* Resolves the native library from:
* 1. Platform-specific npm package (e.g. @ff-labs/fff-bin-darwin-arm64)
* 2. Local dev build (target/release or target/debug)
*/
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getLibFilename, getNpmPackageName } from "./platform";
/**
* Get the current file's directory
*/
function getCurrentDir(): string {
const url = import.meta.url;
// When running in a compiled Bun binary, import.meta.url points to the virtual
// $bunfs filesystem. Use process.execPath to get the real filesystem location.
if (url.includes("$bunfs")) {
return dirname(process.execPath);
}
if (url.startsWith("file://")) {
return dirname(fileURLToPath(url));
}
return dirname(url);
}
/**
* Get the package root directory
*/
function getPackageDir(): string {
const currentDir = getCurrentDir();
return dirname(currentDir);
}
/**
* Check if the binary exists in any known location
*/
export function binaryExists(): boolean {
return findBinary() !== null;
}
/**
* Try to resolve the binary from the platform-specific npm package.
*
* When users install @ff-labs/fff-bun, npm/bun automatically installs the matching
* optionalDependency (e.g. @ff-labs/fff-bin-darwin-arm64). We resolve the binary
* path by requiring that package's package.json and looking for the binary
* in the same directory.
*/
function resolveFromNpmPackage(): string | null {
const packageName = getNpmPackageName();
try {
// Use createRequire to resolve the platform package's location
const require = createRequire(join(getPackageDir(), "package.json"));
const packageJsonPath = require.resolve(`${packageName}/package.json`);
const packageDir = dirname(packageJsonPath);
const binaryPath = join(packageDir, getLibFilename());
if (existsSync(binaryPath)) {
return binaryPath;
}
} catch {
// Package not installed - this is expected on unsupported platforms
// or when installed without optional dependencies
}
return null;
}
/**
* Get the development binary path (for local development)
*/
function getDevBinaryPath(): string | null {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
const possiblePaths = [
join(workspaceRoot, "target", "release", getLibFilename()),
join(workspaceRoot, "target", "debug", getLibFilename()),
];
for (const path of possiblePaths) {
if (existsSync(path)) {
return path;
}
}
return null;
}
function isDevWorkspace(): boolean {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
return existsSync(join(workspaceRoot, "Cargo.toml"));
}
/**
* Find the native library binary.
*
* Resolution order:
* - Dev workspace: local dev build first, then npm package
* - Production: npm package first, then dev build
*
* @returns Absolute path to the library, or null if not found
*/
export function findBinary(): string | null {
if (isDevWorkspace()) {
// 1. Local bin/ directory (populated by `make prepare-bun`)
const binPath = join(getPackageDir(), "bin", getLibFilename());
if (existsSync(binPath)) return binPath;
// 2. Local dev build (target/release or target/debug)
const devPath = getDevBinaryPath();
if (devPath) return devPath;
// 3. Fallback to npm package
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
return null;
}
// Production: npm package first
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
// Fallback: local dev build (e.g. user built from source)
return getDevBinaryPath();
}
+17
View File
@@ -0,0 +1,17 @@
// Native library imports embedded via `bun build --compile` (type: "file").
// Each resolves to the embedded file's path string at runtime.
declare module "*.so" {
const path: string;
export default path;
}
declare module "*.dylib" {
const path: string;
export default path;
}
declare module "*.dll" {
const path: string;
export default path;
}
// Build-time constant injected via `bun build --define FFF_LIBC='"musl"'`.
declare const FFF_LIBC: "gnu" | "musl";
+53
View File
@@ -0,0 +1,53 @@
/**
* Tools for standalone-executable native library embedding.
*
* `bun build --compile` only bundles files referenced by statically analyzable
* imports. Runtime resolution (./download.ts) is invisible to the bundler, so
* we additionally reference the platform's native lib through a `type: "file"`
* import. Bun then embeds it and returns a `$bunfs` path inside the compiled
* binary (and the real on-disk path under `bun run`).
*
* Linux libc cannot be detected at build time, so it is supplied via the
* FFF_LIBC build constant (`bun build --define FFF_LIBC='"musl"'`), defaulting
* to glibc. macOS/Windows need no define.
*/
async function importFile(promise: Promise<{ default: string }>): Promise<string | null> {
try {
return (await promise).default;
} catch {
return null;
}
}
async function resolveEmbeddedLibPath(): Promise<string | null> {
if (process.platform === "darwin") {
return importFile(
import(`@ff-labs/fff-bin-darwin-${process.arch}/libfff_c.dylib`, {
with: { type: "file" },
}),
);
}
if (process.platform === "win32") {
return importFile(
import(`@ff-labs/fff-bin-win32-${process.arch}/fff_c.dll`, {
with: { type: "file" },
}),
);
}
if (process.platform === "linux") {
return importFile(
import(
`@ff-labs/fff-bin-linux-${process.arch}-${typeof FFF_LIBC === "string" ? FFF_LIBC : "gnu"}/libfff_c.so`,
{ with: { type: "file" } }
),
);
}
return null;
}
// Resolved once at module init so loadLibrary() can stay synchronous.
export const embeddedLibPath: string | null = await resolveEmbeddedLibPath();
+609
View File
@@ -0,0 +1,609 @@
// ----------------------------------------------------------------------------
// GENERATED FILE - DO NOT EDIT.
// Copied from: packages/shared/fff-api.ts
// Run make sync-js-api from the repo root to regenerate.
// ----------------------------------------------------------------------------
/**
* The shared public API surface for the fff file finder, implemented identically
* by `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* This file is the single source of truth for every type, helper, and the
* `FileFinderApi` interface that crosses the package boundary. It is copied
* verbatim into each package's `src/fff-api.ts` by `make sync-api`.
*
* Anything that is not part of the public API (FFI struct layouts, binary
* loading, platform detection, etc.) stays as per-package internal
* implementation and must NOT live here.
*
* Keep this file self-contained: it must not import from any package-local
* module, since each package compiles its own copy.
*/
/**
* Result type for all operations - follows the Result pattern
*/
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
/**
* Helper to create a successful result
*/
export function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
/**
* Helper to create an error result
*/
export function err<T>(error: string): Result<T> {
return { ok: false, error };
}
/**
* Initialization options for the file finder
*/
export interface InitOptions {
/** Base directory to index (required) */
basePath: string;
/** Path to frecency database (optional, omit to skip frecency initialization) */
frecencyDbPath?: string;
/** Path to query history database (optional, omit to skip query tracker initialization) */
historyDbPath?: string;
/** @deprecated no-op */
useUnsafeNoLock?: boolean;
/**
* Disable mmap cache warmup after the initial scan. When mmap cache is
* enabled (the default), the first grep search is as fast as subsequent
* ones at the cost of a longer scan time and higher initial memory usage.
*/
disableMmapCache?: boolean;
/**
* Disable the content index built after the initial scan.
* Content indexing enables faster content-aware filtering during grep.
*/
disableContentIndexing?: boolean;
/**
* Disable the background file-system watcher. When the watcher is
* disabled, files are scanned once but not monitored for changes.
* (default: false)
*/
disableWatch?: boolean;
/** enables optimizations for AI agent assistants. Provide as true if running via mcp/agent */
aiMode?: boolean;
/**
* Path to the tracing log file. When set, the shared FFF tracing subscriber
* is installed on first init and file output is written here. Omit to leave
* logging uninitialized.
*/
logFilePath?: string;
/**
* Log level for the tracing subscriber: "trace", "debug", "info", "warn",
* or "error". Defaults to "info". Ignored when `logFilePath` is not set.
*/
logLevel?: "trace" | "debug" | "info" | "warn" | "error";
/**
* Override for the content cache file-count cap. When omitted, the picker
* auto-sizes the budget from the final scanned file count.
*/
cacheBudgetMaxFiles?: number;
/** Override for the content cache byte cap. See `cacheBudgetMaxFiles`. */
cacheBudgetMaxBytes?: number;
/** Override for the per-file byte cap in the content cache. */
cacheBudgetMaxFileSize?: number;
/**
* Allow indexing the filesystem root (`/`).
* Off by default, having fff instance at the large folder will generally require
* file watcher and indexing which will consume a lot of resources if performed uncontrolled
**/
enableFsRootScanning?: boolean;
/** Allow indexing the user's home directory. Same trade-off as `enableFsRootScanning`. */
enableHomeDirScanning?: boolean;
/** Follow symlinks for directories */
followSymlinks?: boolean;
}
/**
* Search options for fuzzy file search
*/
export interface SearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for deprioritization in results) */
currentFile?: string;
/** Combo boost score multiplier (default: 100) */
comboBoostMultiplier?: number;
/** Minimum combo count for boost (default: 3) */
minComboCount?: number;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Options for `glob`, the constraint-only search.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*/
export interface GlobOptions {
/** Maximum threads for parallel filtering (0 = auto). */
maxThreads?: number;
/** Current file path (for deprioritization in results). */
currentFile?: string;
/** Page index for pagination (default: 0). */
pageIndex?: number;
/** Page size for pagination (default: 100). */
pageSize?: number;
}
/**
* A file item in search results
*/
export interface FileItem {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Frecency score based on access patterns */
accessFrecencyScore: number;
/** Frecency score based on modification time */
modificationFrecencyScore: number;
/** Combined frecency score */
totalFrecencyScore: number;
/** Git status: 'clean', 'modified', 'untracked', 'staged_new', etc. */
gitStatus: string;
}
/**
* Score breakdown for a search result
*/
export interface Score {
/** Total combined score */
total: number;
/** Base fuzzy match score */
baseScore: number;
/** Bonus for filename match */
filenameBonus: number;
/** Bonus for special filenames (index.ts, main.rs, etc.) */
specialFilenameBonus: number;
/** Boost from frecency */
frecencyBoost: number;
/** Penalty for distance in path */
distancePenalty: number;
/** Penalty if this is the current file */
currentFilePenalty: number;
/** Boost from query history combo matching */
comboMatchBoost: number;
/** Whether this was an exact match */
exactMatch: boolean;
/** Type of match: 'fuzzy', 'exact', 'prefix', etc. */
matchType: string;
}
/**
* Location in file (from query like "file.ts:42")
*/
export type Location =
| { type: "line"; line: number }
| { type: "position"; line: number; col: number }
| {
type: "range";
start: { line: number; col: number };
end: { line: number; col: number };
};
/**
* Search result from fuzzy file search
*/
export interface SearchResult {
/** Matched file items */
items: FileItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of files that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Location parsed from query (e.g., "file.ts:42:10") */
location?: Location;
}
/**
* A directory item in search results
*/
export interface DirItem {
/** Path relative to the indexed directory (e.g., "src/components/") */
relativePath: string;
/** Last path segment (e.g., "components/" for "src/components/") */
dirName: string;
/** Maximum access frecency score among direct child files */
maxAccessFrecency: number;
}
/**
* Search options for directory search (subset of SearchOptions)
*/
export interface DirSearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for distance scoring) */
currentFile?: string;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Search result from fuzzy directory search
*/
export interface DirSearchResult {
/** Matched directory items */
items: DirItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of directories that matched */
totalMatched: number;
/** Total number of indexed directories */
totalDirs: number;
}
/**
* A single item in a mixed (files + directories) search result
*/
export type MixedItem =
| { type: "file"; item: FileItem }
| { type: "directory"; item: DirItem };
/**
* Search result from mixed (files + directories) fuzzy search.
* Items are interleaved by total score in descending order.
*/
export interface MixedSearchResult {
/** Matched items (files and directories interleaved by score) */
items: MixedItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of items (files + dirs) that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Total number of indexed directories */
totalDirs: number;
/** Location parsed from query */
location?: Location;
}
/**
* Scan progress information
*/
export interface ScanProgress {
/** Number of files scanned so far */
scannedFilesCount: number;
/** Whether a scan is currently in progress */
isScanning: boolean;
/** Whether the background file watcher is ready */
isWatcherReady: boolean;
/** Whether the warmup/bigram phase has completed */
isWarmupComplete: boolean;
}
/**
* Database health information
*/
export interface DbHealth {
/** Path to the database */
path: string;
/** Size of the database on disk in bytes */
diskSize: number;
}
/**
* Health check result
*/
export interface HealthCheck {
/** Library version */
version: string;
/** Git integration status */
git: {
/** Whether git2 library is available */
available: boolean;
/** Whether a git repository was found */
repositoryFound: boolean;
/** Git working directory path */
workdir?: string;
/** libgit2 version string */
libgit2Version: string;
/** Error message if git detection failed */
error?: string;
};
/** File picker status */
filePicker: {
/** Whether the file picker is initialized */
initialized: boolean;
/** Base path being indexed */
basePath?: string;
/** Whether a scan is in progress */
isScanning?: boolean;
/** Number of indexed files */
indexedFiles?: number;
/** Error message if there's an issue */
error?: string;
};
/** Frecency database status */
frecency: {
/** Whether frecency tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
/** Query tracker status */
queryTracker: {
/** Whether query tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
}
/**
* Grep search mode
*/
export type GrepMode = "plain" | "regex" | "fuzzy";
/**
* Opaque pagination cursor for grep results.
* Pass this to `GrepOptions.cursor` to fetch the next page.
* Do not construct or modify this — use the `nextCursor` from a previous `GrepResult`.
*/
export interface GrepCursor {
/** @internal */
readonly __brand: "GrepCursor";
/** @internal */
readonly _offset: number;
}
/**
* @internal Create a GrepCursor from a raw file offset.
*/
export function createGrepCursor(offset: number): GrepCursor {
return { __brand: "GrepCursor" as const, _offset: offset };
}
/**
* Options for live grep (content search)
*
* Files are searched sequentially in frecency order (most recently/frequently
* accessed first). The engine returns a `nextCursor` for fetching the next page.
*/
export interface GrepOptions {
/** Maximum file size to search in bytes. Files larger than this are skipped. (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 200) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when the query is all lowercase, case-sensitive otherwise (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/** Search mode (default: "plain") */
mode?: GrepMode;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. Let callers re-rank defs first
* without a TS-side regex port. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* A single grep match with file and line information
*/
export interface GrepMatch {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** Git status */
gitStatus: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Whether the file is binary */
isBinary: boolean;
/** Combined frecency score */
totalFrecencyScore: number;
/** Access-based frecency score */
accessFrecencyScore: number;
/** Modification-based frecency score */
modificationFrecencyScore: number;
/** 1-based line number of the match */
lineNumber: number;
/** 0-based byte column of first match start */
col: number;
/** Absolute byte offset of the matched line from file start */
byteOffset: number;
/** The matched line text (may be truncated) */
lineContent: string;
/** Byte offset pairs [start, end] within lineContent for highlighting */
matchRanges: [number, number][];
/** Fuzzy match score (only in fuzzy mode) */
fuzzyScore?: number;
/** Lines before the match (context). Empty array when context is 0. */
contextBefore?: string[];
/** Lines after the match (context). Empty array when context is 0. */
contextAfter?: string[];
/** Whether this line is a code definition (only populated when `classifyDefinitions: true`). */
isDefinition?: boolean;
}
/**
* Result from a grep search
*/
export interface GrepResult {
/** Matched items with file and line information. At most `max_matches_per_file`. */
items: GrepMatch[];
/** Total number of matches collected (always equal to items.length). */
totalMatched: number;
/** Number of files actually opened and searched in this call */
totalFilesSearched: number;
/** Total number of indexed files (before any filtering) */
totalFiles: number;
/** Number of files eligible for search after filtering out binary files, oversized files, and constraint mismatches */
filteredFileCount: number;
/**
* Cursor for the next page, or `null` if all eligible files have been searched.
* Pass this as `GrepOptions.cursor` to continue from where this call left off.
*/
nextCursor: GrepCursor | null;
/** When regex mode fails to compile the pattern, the engine falls back to literal matching and this field contains the compilation error */
regexFallbackError?: string;
}
/**
* Options for multi-pattern grep (Aho-Corasick multi-needle search)
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated Aho-Corasick multi-pattern matching.
*/
export interface MultiGrepOptions {
/** Patterns to search for (OR logic — matches lines containing any pattern) */
patterns: string[];
/** File constraints like "*.rs" or "/src/" */
constraints?: string;
/** Maximum file size to search in bytes (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 0 = unlimited) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when all patterns are lowercase (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* The shared instance surface implemented by `FileFinder` in both
* `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* Both packages must implement this identically. Only instance members belong
* here. Static helpers (`create`, `isAvailable`, `ensureLoaded`,
* `healthCheckStatic`) are package-specific and intentionally excluded.
*/
export interface FileFinderApi {
/** Whether the instance has been destroyed. */
readonly isDestroyed: boolean;
/** Destroy and free all native resources. */
destroy(): void;
/** Fuzzy file search. */
fileSearch(query: string, options?: SearchOptions): Result<SearchResult>;
/** Glob-only filtering (no fuzzy matching). */
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
/** Fuzzy directory search. */
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
/** Fuzzy search over files and directories interleaved by score. */
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
/** Content search (live grep). */
grep(query: string, options?: GrepOptions): Result<GrepResult>;
/** Multi-pattern OR content search (Aho-Corasick). */
multiGrep(options: MultiGrepOptions): Result<GrepResult>;
/** Trigger an async rescan of the indexed directory. */
scanFiles(): Result<void>;
/** Whether a scan is currently in progress. */
isScanning(): boolean;
/** The root directory being indexed. */
getBasePath(): Result<string | null>;
/** Current scan progress snapshot. */
getScanProgress(): Result<ScanProgress>;
/**
* Wait for the initial file scan to complete.
*
* Non-blocking: polls `isScanning` and yields to the event loop between
* checks, so other async work keeps running while waiting.
*/
waitForScan(timeoutMs?: number): Promise<Result<boolean>>;
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer `waitForScan` unless
* you specifically need synchronous blocking behaviour.
*/
waitForScanBlocking(timeoutMs?: number): Result<boolean>;
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*/
waitForIndexReady(timeoutMs?: number): Promise<Result<boolean>>;
/** Restart indexing in a new directory. */
reindex(newPath: string): Result<void>;
/** Refresh the git status cache. Returns the number of updated files. */
refreshGitStatus(): Result<number>;
/** Record that `selectedFilePath` was chosen for `query`. */
trackQuery(query: string, selectedFilePath: string): Result<boolean>;
/** Get a historical query by offset (0 = most recent). */
getHistoricalQuery(offset: number): Result<string | null>;
/** Health/diagnostics information for this instance. */
healthCheck(testPath?: string): Result<HealthCheck>;
}
File diff suppressed because it is too large Load Diff
+607
View File
@@ -0,0 +1,607 @@
/**
* FileFinder - High-level API for the fff file finder
*
* This class provides a type-safe, ergonomic API for file finding operations.
* Each instance owns an independent native file picker that can be created
* and destroyed independently. Multiple instances can coexist.
*
* All methods return Result types for explicit error handling.
*/
import {
ensureLoaded,
ffiCreate,
ffiDestroy,
ffiGetBasePath,
ffiGetHistoricalQuery,
ffiGetScanProgress,
ffiGlob,
ffiHealthCheck,
ffiIsScanning,
ffiLiveGrep,
ffiMultiGrep,
ffiRefreshGitStatus,
ffiRestartIndex,
ffiScanFiles,
ffiSearch,
ffiSearchDirectories,
ffiSearchMixed,
ffiTrackQuery,
ffiWaitForScan,
isAvailable,
type NativeHandle,
} from "./ffi";
import type {
DirSearchOptions,
DirSearchResult,
InitOptions as FFFInitOptions,
FileFinderApi,
GlobOptions,
GrepOptions,
GrepResult,
HealthCheck,
MixedSearchResult,
MultiGrepOptions,
Result,
ScanProgress,
SearchOptions,
SearchResult,
} from "./fff-api";
import { err } from "./fff-api";
/**
* FileFinder - Fast file finder with fuzzy search
*
* Each instance is backed by an independent native file picker. Create as many
* as you need and destroy them when done.
*
* @example
* ```typescript
* import { FileFinder } from "fff";
*
* // Create an instance
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (!finder.ok) {
* console.error(finder.error);
* process.exit(1);
* }
*
* // Wait for initial scan
* await finder.value.waitForScan(5000);
*
* // Search for files
* const search = finder.value.search("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
* }
* }
*
* // Cleanup
* finder.value.destroy();
* ```
*/
export class FileFinder implements FileFinderApi {
private handle: NativeHandle | null;
private constructor(handle: NativeHandle) {
this.handle = handle;
}
/**
* Create a new file finder instance.
*
* @param options - Initialization options
* @returns Result containing the new FileFinder instance or an error
*
* @example
* ```typescript
* // Basic initialization
* const finder = FileFinder.create({ basePath: "/path/to/project" });
*
* // With custom database paths
* const finder = FileFinder.create({
* basePath: "/path/to/project",
* frecencyDbPath: "/custom/frecency.mdb",
* historyDbPath: "/custom/history.mdb",
* });
* ```
*/
static create(options: FFFInitOptions): Result<FileFinder> {
const result = ffiCreate(
options.basePath,
options.frecencyDbPath ?? "",
options.historyDbPath ?? "",
options.useUnsafeNoLock ?? false,
!(options.disableMmapCache ?? false),
!(options.disableContentIndexing ?? options.disableMmapCache ?? false),
!(options.disableWatch ?? false),
options.aiMode ?? false,
options.logFilePath ?? "",
options.logLevel ?? "",
BigInt(options.cacheBudgetMaxFiles ?? 0),
BigInt(options.cacheBudgetMaxBytes ?? 0),
BigInt(options.cacheBudgetMaxFileSize ?? 0),
options.enableFsRootScanning ?? false,
options.enableHomeDirScanning ?? false,
);
if (!result.ok) {
return result;
}
return { ok: true, value: new FileFinder(result.value) };
}
/**
* Destroy and clean up all resources.
*
* Call this when you're done using the file finder to free memory
* and stop background file watching. After calling this, the instance
* must not be used again.
*/
destroy(): void {
if (this.handle !== null) {
ffiDestroy(this.handle);
this.handle = null;
}
}
/**
* Check if this instance has been destroyed.
*/
get isDestroyed(): boolean {
return this.handle === null;
}
/**
* Guard that returns an error if the instance has been destroyed.
*/
private ensureAlive(): Result<NativeHandle> {
if (this.handle === null) {
return err("FileFinder instance has been destroyed.");
}
return { ok: true, value: this.handle };
}
/**
* Search for files matching the query.
*
* The query supports fuzzy matching and special syntax:
* - `foo bar` - Match files containing "foo" and "bar"
* - `src/` - Match files in src directory
* - `file.ts:42` - Match file.ts with line 42
* - `file.ts:42:10` - Match file.ts with line 42, column 10
*
* @param query - Search query string
* @param options - Search options
* @returns Search results with matched files and scores
*
* @example
* ```typescript
* const result = finder.search("main.ts", { pageSize: 10 });
* if (result.ok) {
* console.log(`Found ${result.value.totalMatched} files`);
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
fileSearch(query: string, options?: SearchOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearch(
guard.value,
query,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
options?.comboBoostMultiplier ?? 0,
options?.minComboCount ?? 0,
);
}
/**
* Glob-only search.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*
* @param pattern - Glob pattern (required, non-empty)
* @param options - Glob search options (pagination, max threads, current file)
* @returns Search results with files matching the glob
*/
glob(pattern: string, options?: GlobOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGlob(
guard.value,
pattern,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
);
}
/**
* Search for directories matching the query.
*
* @param query - Search query string
* @param options - Directory search options
* @returns Search results with matched directories and scores
*
* @example
* ```typescript
* const result = finder.directorySearch("components", { pageSize: 10 });
* if (result.ok) {
* console.log(`Found ${result.value.totalMatched} directories`);
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearchDirectories(
guard.value,
query,
options?.currentFile ?? null,
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
);
}
/**
* Search for files and directories together (mixed search).
*
* Results are interleaved by total score in descending order.
*
* @param query - Search query string
* @param options - Search options
* @returns Mixed search results with files and directories interleaved by score
*
* @example
* ```typescript
* const result = finder.mixedSearch("main", { pageSize: 20 });
* if (result.ok) {
* for (const entry of result.value.items) {
* if (entry.type === "file") {
* console.log(`File: ${entry.item.relativePath}`);
* } else {
* console.log(`Dir: ${entry.item.relativePath}`);
* }
* }
* }
* ```
*/
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearchMixed(
guard.value,
query,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
options?.comboBoostMultiplier ?? 0,
options?.minComboCount ?? 0,
);
}
/**
* Search file contents (live grep).
*
* Searches through the contents of indexed files using the specified mode:
* - `"plain"` (default): SIMD-accelerated literal text matching
* - `"regex"`: Regular expression matching
* - `"fuzzy"`: Smith-Waterman fuzzy matching per line
*
* Supports pagination for large result sets. The result includes a `nextCursor`
* that can be passed back to fetch the next page.
*
* The query also supports constraint syntax:
* - `*.ts pattern` - Only search in TypeScript files
* - `src/ pattern` - Only search in the src directory
*
* @param query - Search query string
* @param options - Grep options (mode, pagination, limits)
* @returns Grep results with matched lines and file metadata
*
* @example
* ```typescript
* // First page
* const result = finder.grep("TODO", { mode: "plain" });
* if (result.ok) {
* for (const match of result.value.items) {
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
* }
* // Fetch next page
* if (result.value.nextCursor) {
* const page2 = finder.grep("TODO", {
* cursor: result.value.nextCursor,
* });
* }
* }
* ```
*/
grep(query: string, options?: GrepOptions): Result<GrepResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiLiveGrep(
guard.value,
query,
options?.mode ?? "plain",
options?.maxFileSize ?? 0,
options?.maxMatchesPerFile ?? 0,
options?.smartCase ?? true,
options?.cursor?._offset ?? 0,
options?.pageSize ?? 0,
options?.timeBudgetMs ?? 0,
options?.beforeContext ?? 0,
options?.afterContext ?? 0,
options?.classifyDefinitions ?? false,
);
}
/**
* Multi-pattern OR search using Aho-Corasick.
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated multi-needle matching. Faster than regex alternation
* for literal text searches.
*
* Supports pagination. The result includes a `nextCursor` that can be
* passed back to fetch the next page.
*
* @param options - Multi-grep options including patterns and optional constraints
* @returns Grep results with matched lines and file metadata
*
* @example
* ```typescript
* const result = finder.multiGrep({
* patterns: ["VideoFrame", "video_frame", "PreloadedImage"],
* });
* if (result.ok) {
* for (const match of result.value.items) {
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
* }
* }
* ```
*/
multiGrep(options: MultiGrepOptions): Result<GrepResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
if (!options.patterns || options.patterns.length === 0) {
return err("patterns array must have at least 1 element");
}
return ffiMultiGrep(
guard.value,
options.patterns.join("\n"),
options.constraints ?? "",
options.maxFileSize ?? 0,
options.maxMatchesPerFile ?? 0,
options.smartCase ?? true,
options.cursor?._offset ?? 0,
options.pageSize ?? 0,
options.timeBudgetMs ?? 0,
options.beforeContext ?? 0,
options.afterContext ?? 0,
options.classifyDefinitions ?? false,
);
}
/**
* Trigger a rescan of the indexed directory.
*
* This is useful after major file system changes that the
* background watcher might have missed.
*/
scanFiles(): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiScanFiles(guard.value);
}
/**
* Check if a scan is currently in progress.
*/
isScanning(): boolean {
if (this.handle === null) return false;
return ffiIsScanning(this.handle);
}
/**
* Get the base path of the file picker (the root directory being indexed).
*/
getBasePath(): Result<string | null> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetBasePath(guard.value);
}
/**
* Get the current scan progress.
*/
getScanProgress(): Result<ScanProgress> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetScanProgress(guard.value) as Result<ScanProgress>;
}
/**
* Wait for the initial file scan to complete.
*
* Non-blocking: polls `isScanning` and yields to the event loop between
* checks, so other async work keeps running while waiting.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*
* @example
* ```typescript
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (finder.ok) {
* const completed = await finder.value.waitForScan(10000);
* if (!completed.ok || !completed.value) {
* console.warn("Scan did not complete in time");
* }
* }
* ```
*/
async waitForScan(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const deadline = Date.now() + timeoutMs;
while (this.isScanning()) {
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return { ok: true, value: true };
}
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer {@link waitForScan}
* unless you specifically need synchronous blocking behaviour.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*/
waitForScanBlocking(timeoutMs: number = 5000): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiWaitForScan(guard.value, timeoutMs);
}
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking — polls `getScanProgress` and yields to the event loop.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if the index became ready, false if timed out
*/
async waitForIndexReady(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const deadline = Date.now() + timeoutMs;
while (true) {
const progress = this.getScanProgress();
if (!progress.ok) return progress;
if (!progress.value.isScanning && progress.value.isWarmupComplete) {
return { ok: true, value: true };
}
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/**
* Change the indexed directory to a new path.
*
* This stops the current file watcher and starts indexing the new directory.
*
* @param newPath - New directory path to index
*/
reindex(newPath: string): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRestartIndex(guard.value, newPath);
}
/**
* Refresh the git status cache.
*
* @returns Number of files with updated git status
*/
refreshGitStatus(): Result<number> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRefreshGitStatus(guard.value);
}
/**
* Track query completion for smart suggestions.
*
* Call this when a user selects a file from search results.
* This helps improve future search rankings for similar queries.
*
* @param query - The search query that was used
* @param selectedFilePath - The file path that was selected
*/
trackQuery(query: string, selectedFilePath: string): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiTrackQuery(guard.value, query, selectedFilePath);
}
/**
* Get a historical query by offset.
*
* @param offset - Offset from most recent (0 = most recent)
* @returns The historical query string, or null if not found
*/
getHistoricalQuery(offset: number): Result<string | null> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetHistoricalQuery(guard.value, offset);
}
/**
* Get health check information.
*
* Useful for debugging and verifying the file finder is working correctly.
*
* @param testPath - Optional path to test git repository detection
*/
healthCheck(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(this.handle, testPath || "") as Result<HealthCheck>;
}
/**
* Check if the native library is available.
*/
static isAvailable(): boolean {
return isAvailable();
}
/** Ensure the native library is loaded. */
static ensureLoaded(): void {
ensureLoaded();
}
/**
* Get a health check without requiring an instance.
*
* Returns limited info (version + git only, no picker/frecency/query data).
*
* @param testPath - Optional path to test git repository detection
*/
static healthCheckStatic(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
}
}
+35
View File
@@ -0,0 +1,35 @@
export { binaryExists, findBinary } from "./download";
export type {
DbHealth,
DirItem,
DirSearchOptions,
DirSearchResult,
err,
FileFinderApi,
FileItem,
GrepCursor,
GrepMatch,
GrepMode,
GrepOptions,
GrepResult,
HealthCheck,
InitOptions,
Location,
MixedItem,
MixedSearchResult,
MultiGrepOptions,
ok,
Result,
ScanProgress,
Score,
SearchOptions,
SearchResult,
} from "./fff-api";
export { FileFinder } from "./finder";
export {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "./platform";
+125
View File
@@ -0,0 +1,125 @@
/**
* Platform detection utilities for downloading the correct binary
*/
import { execSync } from "node:child_process";
/**
* Get the platform triple (e.g., "x86_64-unknown-linux-gnu")
*/
export function getTriple(): string {
const platform = process.platform;
const arch = process.arch;
let osName: string;
if (platform === "darwin") {
osName = "apple-darwin";
} else if (platform === "linux") {
osName = detectLinuxLibc();
} else if (platform === "win32") {
osName = "pc-windows-msvc";
} else {
throw new Error(`Unsupported platform: ${platform}`);
}
const archName = normalizeArch(arch);
return `${archName}-${osName}`;
}
/**
* Detect whether we're on musl or glibc Linux
*/
function detectLinuxLibc(): string {
let output = "";
try {
output = execSync("ldd --version 2>&1", {
encoding: "utf-8",
timeout: 5000,
});
} catch (e: unknown) {
// Alpine/musl: `ldd --version` exits with code 1 but still prints
// "musl libc ..." — execSync surfaces that on the error object.
const err = e as { stdout?: string | Buffer; stderr?: string | Buffer };
output = String(err?.stdout ?? "") + String(err?.stderr ?? "");
}
if (output.toLowerCase().includes("musl")) {
return "unknown-linux-musl";
}
return "unknown-linux-gnu";
}
/**
* Normalize architecture name to Rust target format
*/
function normalizeArch(arch: string): string {
switch (arch) {
case "x64":
case "amd64":
return "x86_64";
case "arm64":
return "aarch64";
case "arm":
return "arm";
default:
throw new Error(`Unsupported architecture: ${arch}`);
}
}
/**
* Get the library file extension for the current platform
*/
export function getLibExtension(): "dylib" | "so" | "dll" {
switch (process.platform) {
case "darwin":
return "dylib";
case "win32":
return "dll";
default:
return "so";
}
}
/**
* Get the library filename prefix (empty on Windows)
*/
export function getLibPrefix(): string {
return process.platform === "win32" ? "" : "lib";
}
/**
* Get the full library filename for the current platform
*/
export function getLibFilename(): string {
const prefix = getLibPrefix();
const ext = getLibExtension();
return `${prefix}fff_c.${ext}`;
}
/**
* Map from Rust target triple to npm platform package name
*/
const TRIPLE_TO_NPM_PACKAGE: Record<string, string> = {
"aarch64-apple-darwin": "@ff-labs/fff-bin-darwin-arm64",
"x86_64-apple-darwin": "@ff-labs/fff-bin-darwin-x64",
"x86_64-unknown-linux-gnu": "@ff-labs/fff-bin-linux-x64-gnu",
"aarch64-unknown-linux-gnu": "@ff-labs/fff-bin-linux-arm64-gnu",
"x86_64-unknown-linux-musl": "@ff-labs/fff-bin-linux-x64-musl",
"aarch64-unknown-linux-musl": "@ff-labs/fff-bin-linux-arm64-musl",
"x86_64-pc-windows-msvc": "@ff-labs/fff-bin-win32-x64",
"aarch64-pc-windows-msvc": "@ff-labs/fff-bin-win32-arm64",
};
/**
* Get the npm package name for the current platform's native binary.
*
* @returns Package name like "@ff-labs/fff-bin-darwin-arm64"
* @throws If the current platform is not supported
*/
export function getNpmPackageName(): string {
const triple = getTriple();
const packageName = TRIPLE_TO_NPM_PACKAGE[triple];
if (!packageName) {
throw new Error(`No npm package available for platform: ${triple}`);
}
return packageName;
}
+156
View File
@@ -0,0 +1,156 @@
import { FileFinder } from "./src/index";
import { resolve, dirname } from "node:path";
async function main() {
console.log("=== fff Test Script ===\n");
// Check if library is available
console.log("Checking library availability...");
const available = FileFinder.isAvailable();
console.log(`Library available: ${available}\n`);
if (!available) {
console.error("Native library not found!");
console.error("Build it with: cargo build --release -p fff-c");
process.exit(1);
}
// Health check (before creating instance)
console.log("Health check (no instance):");
const healthBefore = FileFinder.healthCheckStatic();
if (healthBefore.ok) {
console.log(` Version: ${healthBefore.value.version}`);
console.log(` Git available: ${healthBefore.value.git.available}`);
console.log(
` File picker initialized: ${healthBefore.value.filePicker.initialized}`,
);
} else {
console.error(` Error: ${healthBefore.error}`);
}
console.log();
// Initialize with the root project directory to test on more files
const testDir = resolve(dirname(import.meta.path), "../..");
console.log(`Creating instance with base path: ${testDir}`);
const createResult = FileFinder.create({
basePath: testDir,
});
if (!createResult.ok) {
console.error(`Create failed: ${createResult.error}`);
process.exit(1);
}
const finder = createResult.value;
console.log("Instance created successfully!\n");
// Wait for scan with polling to show progress
console.log("Waiting for initial scan...");
const startTime = Date.now();
let lastCount = 0;
while (finder.isScanning()) {
const progress = finder.getScanProgress();
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
lastCount = progress.value.scannedFilesCount;
console.log(` Scanning: ${lastCount} files...`);
}
await new Promise((r) => setTimeout(r, 100));
if (Date.now() - startTime > 30000) {
console.error(" Scan timeout after 30s");
break;
}
}
// Get final scan progress
const progress = finder.getScanProgress();
if (progress.ok) {
console.log(`Scan complete: ${progress.value.scannedFilesCount} files indexed`);
console.log(`Scan time: ${Date.now() - startTime}ms`);
}
console.log();
// Search test
console.log("Searching for 'lib.rs'...");
const searchResult = finder.fileSearch("lib.rs", { pageSize: 5 });
if (searchResult.ok) {
console.log(`Found ${searchResult.value.totalMatched} matches (showing first 5):\n`);
for (let i = 0; i < searchResult.value.items.length; i++) {
const item = searchResult.value.items[i];
const score = searchResult.value.scores[i];
console.log(` ${item.relativePath}`);
console.log(
` Score: ${score.total} (base: ${score.baseScore}, filename: ${score.filenameBonus})`,
);
console.log(` Git: ${item.gitStatus}`);
}
} else {
console.error(`Search failed: ${searchResult.error}`);
}
console.log();
// Search with different query
console.log("Searching for 'package.json'...");
const searchResult2 = finder.fileSearch("package.json", { pageSize: 3 });
if (searchResult2.ok) {
console.log(`Found ${searchResult2.value.totalMatched} matches:\n`);
for (const item of searchResult2.value.items) {
console.log(` ${item.relativePath}`);
}
} else {
console.error(`Search failed: ${searchResult2.error}`);
}
console.log();
// Health check (after init)
console.log("Health check (with instance):");
const healthAfter = finder.healthCheck();
if (healthAfter.ok) {
console.log(` File picker initialized: ${healthAfter.value.filePicker.initialized}`);
console.log(` Base path: ${healthAfter.value.filePicker.basePath}`);
console.log(` Indexed files: ${healthAfter.value.filePicker.indexedFiles}`);
if (healthAfter.value.git.repositoryFound) {
console.log(` Git workdir: ${healthAfter.value.git.workdir}`);
}
}
console.log();
// Test multiple instances
console.log("Testing multiple instances...");
const finder2Result = FileFinder.create({ basePath: testDir });
if (finder2Result.ok) {
const finder2 = finder2Result.value;
console.log(" Second instance created successfully");
finder2.waitForScanBlocking(5000);
const search2 = finder2.fileSearch("Cargo.toml");
if (search2.ok) {
console.log(
` Second instance found ${search2.value.totalMatched} matches for 'Cargo.toml'`,
);
}
finder2.destroy();
console.log(" Second instance destroyed");
// First instance should still work
const search3 = finder.fileSearch("Cargo.toml");
if (search3.ok) {
console.log(` First instance still works: ${search3.value.totalMatched} matches`);
}
}
console.log();
// Cleanup
console.log("Cleaning up...");
finder.destroy();
console.log(`Cleanup successful! (isDestroyed: ${finder.isDestroyed})`);
console.log("\n=== Test Complete ===");
}
main().catch(console.error);
+367
View File
@@ -0,0 +1,367 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { execSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { FileItem } from "../src/fff-api";
import { FileFinder } from "../src/index";
/**
* Integration test: full git lifecycle with a real repository.
*
* Creates a temporary git repo, initialises a FileFinder instance pointing at
* it, then walks through:
* 1. Initial scan committed files should have status "clean"
* 2. Add a new untracked file should appear as "untracked"
* 3. Stage the new file should appear as "staged_new"
* 4. Commit should become "clean"
* 5. Modify a tracked file should become "modified"
* 6. Stage the modification should become "staged_modified"
* 7. Commit again back to "clean"
* 8. Delete a file should disappear from the index
*/
const POLL_INTERVAL_MS = 100;
const WATCHER_TIMEOUT_MS = 5_000; // generous CI timeout; polls exit early on fast machines
function git(cwd: string, ...args: string[]) {
const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
execSync(`git ${escaped}`, {
cwd,
stdio: "pipe",
env: {
...process.env,
GIT_AUTHOR_NAME: "test",
GIT_AUTHOR_EMAIL: "test@test.com",
GIT_COMMITTER_NAME: "test",
GIT_COMMITTER_EMAIL: "test@test.com",
},
});
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function findFile(finder: FileFinder, name: string): FileItem | undefined {
const result = finder.fileSearch(name, { pageSize: 200 });
if (!result.ok) throw new Error(`search failed: ${result.error}`);
return result.value.items.find((item) => item.fileName === name);
}
/** Poll until a file appears in the index, or the timeout is exceeded. */
async function waitForFile(
finder: FileFinder,
name: string,
): Promise<FileItem | undefined> {
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
const file = findFile(finder, name);
if (file !== undefined) return file;
await sleep(POLL_INTERVAL_MS);
}
return findFile(finder, name);
}
/** Poll until a file has the expected git status, or the timeout is exceeded. */
async function waitForFileStatus(
finder: FileFinder,
name: string,
status: string,
): Promise<FileItem | undefined> {
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
const file = findFile(finder, name);
if (file?.gitStatus === status) return file;
await sleep(POLL_INTERVAL_MS);
}
return findFile(finder, name);
}
/** Poll until a file is gone from the index, or the timeout is exceeded. */
async function waitForFileGone(finder: FileFinder, name: string): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
if (findFile(finder, name) === undefined) return true;
await sleep(POLL_INTERVAL_MS);
}
return findFile(finder, name) === undefined;
}
/** Poll until the total file count reaches the expected value, or the timeout is exceeded. */
async function waitForFileCount(finder: FileFinder, count: number): Promise<number> {
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
const result = finder.fileSearch("", { pageSize: 200 });
if (result.ok && result.value.totalFiles === count) return count;
await sleep(POLL_INTERVAL_MS);
}
const result = finder.fileSearch("", { pageSize: 200 });
return result.ok ? result.value.totalFiles : -1;
}
/** Poll grep until predicate on totalMatched is satisfied, or the timeout is exceeded. */
async function waitForGrep(
finder: FileFinder,
pattern: string,
options: { mode: "plain" | "regex" },
predicate: (totalMatched: number) => boolean,
) {
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
const result = finder.grep(pattern, options);
if (result.ok && predicate(result.value.totalMatched)) return result;
await sleep(POLL_INTERVAL_MS);
}
return finder.grep(pattern, options);
}
describe.skipIf(process.platform === "win32")("Git lifecycle integration", () => {
let tmpDir: string;
let finder: FileFinder;
beforeAll(async () => {
// Create temp directory and initialise a git repo with two committed files.
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
// that git2's resolved workdir paths match the file picker's base_path.
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
git(tmpDir, "init", "-b", "main");
// Need at least one commit for status to work properly
writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
mkdirSync(join(tmpDir, "src"));
writeFileSync(join(tmpDir, "src", "main.rs"), 'fn main() { println?."hi"); }\n');
git(tmpDir, "add", "-A");
git(tmpDir, "commit", "-m", "initial commit");
// Create the FileFinder instance
const result = FileFinder.create({ basePath: tmpDir });
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error);
finder = result.value;
// Wait for the initial scan to finish
const scanResult = finder.waitForScanBlocking(10_000);
expect(scanResult.ok).toBe(true);
// Poll getScanProgress until the watcher is ready so that
// filesystem events (file creates, deletes) are detected.
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
const progress = finder.getScanProgress();
if (progress.ok && progress.value.isWatcherReady) break;
await sleep(POLL_INTERVAL_MS);
}
const progress = finder.getScanProgress();
expect(progress.ok).toBe(true);
if (progress.ok) {
expect(progress.value.isWatcherReady).toBe(true);
}
});
afterAll(() => {
finder?.destroy();
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
test("initial scan indexes all committed files", () => {
const result = finder.fileSearch("", { pageSize: 200 });
expect(result.ok).toBe(true);
if (!result.ok) return;
const names = result.value.items.map((i) => i.relativePath).sort();
expect(names).toContain("hello.txt");
expect(names).toContain("readme.md");
expect(names).toContain("src/main.rs");
expect(result.value.totalFiles).toBe(3);
});
test("committed files have clean git status", async () => {
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("clean");
const main = await waitForFileStatus(finder, "main.rs", "clean");
expect(main).toBeDefined();
expect(main?.gitStatus).toBe("clean");
});
test("new untracked file appears with 'untracked' status", async () => {
writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
const newFile = await waitForFileStatus(finder, "new_file.ts", "untracked");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("untracked");
// Total should now be 4
const total = await waitForFileCount(finder, 4);
expect(total).toBe(4);
});
test("staging a new file changes status to 'staged_new'", async () => {
git(tmpDir, "add", "new_file.ts");
const newFile = await waitForFileStatus(finder, "new_file.ts", "staged_new");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("staged_new");
});
test("committing makes the file 'clean'", async () => {
git(tmpDir, "commit", "-m", "add new_file");
const newFile = await waitForFileStatus(finder, "new_file.ts", "clean");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("clean");
});
test("modifying a tracked file changes status to 'modified'", async () => {
writeFileSync(join(tmpDir, "hello.txt"), "hello world\nupdated content\n");
const hello = await waitForFileStatus(finder, "hello.txt", "modified");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("modified");
});
test("staging a modification changes status to 'staged_modified'", async () => {
git(tmpDir, "add", "hello.txt");
const hello = await waitForFileStatus(finder, "hello.txt", "staged_modified");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("staged_modified");
});
test("committing the modification returns to 'clean'", async () => {
git(tmpDir, "commit", "-m", "update hello");
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("clean");
});
test("deleting a file removes it from the index", async () => {
unlinkSync(join(tmpDir, "new_file.ts"));
const gone = await waitForFileGone(finder, "new_file.ts");
expect(gone).toBe(true);
// Total should be back to 3
const total = await waitForFileCount(finder, 3);
expect(total).toBe(3);
});
test("adding a file in a subdirectory works", async () => {
writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
const utils = await waitForFileStatus(finder, "utils.rs", "untracked");
expect(utils).toBeDefined();
expect(utils?.relativePath).toBe("src/utils.rs");
expect(utils?.gitStatus).toBe("untracked");
});
test("live grep finds content in a newly added file", async () => {
writeFileSync(
join(tmpDir, "src", "searchtarget.rs"),
'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
);
await waitForFile(finder, "searchtarget.rs");
const result = await waitForGrep(
finder,
"xylophone_waterfall_97",
{ mode: "plain" },
(n) => n > 0,
);
expect(result?.ok).toBe(true);
if (!result?.ok) return;
expect(result.value.totalMatched).toBeGreaterThan(0);
const match = result.value.items.find(
(m) => m.relativePath === "src/searchtarget.rs",
);
expect(match).toBeDefined();
expect(match!.lineContent).toContain("xylophone_waterfall_97");
});
test("live grep no longer finds content after file is deleted", async () => {
unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
const result = await waitForGrep(
finder,
"xylophone_waterfall_97",
{ mode: "plain" },
(n) => n === 0,
);
expect(result?.ok).toBe(true);
if (!result?.ok) return;
expect(result.value.totalMatched).toBe(0);
expect(result.value.items.length).toBe(0);
});
test("file in a newly created directory is discoverable", async () => {
// Create a brand-new directory that didn't exist during the initial scan,
// then add a file inside it. The watcher must dynamically pick up the new
// directory and index the file.
mkdirSync(join(tmpDir, "lib"));
writeFileSync(
join(tmpDir, "lib", "helpers.ts"),
"export function add(a: number, b: number) { return a + b; }\n",
);
const helpers = await waitForFile(finder, "helpers.ts");
expect(helpers).toBeDefined();
expect(helpers?.relativePath).toBe("lib/helpers.ts");
});
test("files in gitignored directories are not indexed", async () => {
// Commit a .gitignore rule first so it's established repo state before
// the ignored directory is created. This tests the watch-level filtering
// (is_path_ignored in the debouncer callback), not a rescan triggered
// by a .gitignore change.
writeFileSync(join(tmpDir, ".gitignore"), "build_output/\n");
git(tmpDir, "add", ".gitignore");
git(tmpDir, "commit", "-m", "add gitignore");
// Wait for the watcher to settle after the commit.
await waitForFile(finder, ".gitignore");
// Now create the ignored directory and add a file inside it.
mkdirSync(join(tmpDir, "build_output"));
writeFileSync(join(tmpDir, "build_output", "artifact.bin"), "should not appear\n");
// Create a non-ignored file as a synchronisation barrier — once it's
// indexed, the watcher has processed the same batch of events.
writeFileSync(join(tmpDir, "canary.txt"), "visible\n");
const canary = await waitForFile(finder, "canary.txt");
expect(canary).toBeDefined();
// The ignored file must NOT appear in the index.
const artifact = findFile(finder, "artifact.bin");
expect(artifact).toBeUndefined();
});
test("full add-commit cycle for subdirectory file", async () => {
git(tmpDir, "add", "src/utils.rs");
let utils = await waitForFileStatus(finder, "utils.rs", "staged_new");
expect(utils).toBeDefined();
expect(utils?.gitStatus).toBe("staged_new");
git(tmpDir, "commit", "-m", "add utils");
utils = await waitForFileStatus(finder, "utils.rs", "clean");
expect(utils).toBeDefined();
expect(utils?.gitStatus).toBe("clean");
});
});
+503
View File
@@ -0,0 +1,503 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import { findBinary } from "../src/download";
import { FileFinder } from "../src/index";
import { getLibExtension, getLibFilename, getTriple } from "../src/platform";
// Cross-platform path normalization helpers
const normalizePath = (path: string | null | undefined): string | null => {
if (!path) return null;
// Convert backslashes to forward slashes for consistent comparison
return path.replace(/\\/g, "/");
};
const testDir = process.cwd();
describe("Platform Detection", () => {
test("getTriple returns valid triple", () => {
const triple = getTriple();
expect(triple).toMatch(
/^(x86_64|aarch64|arm)-(apple-darwin|unknown-linux-(gnu|musl)|pc-windows-msvc)$/,
);
});
test("getLibExtension returns correct extension", () => {
const ext = getLibExtension();
const platform = process.platform;
if (platform === "darwin") {
expect(ext).toBe("dylib");
} else if (platform === "win32") {
expect(ext).toBe("dll");
} else {
expect(ext).toBe("so");
}
});
test("getLibFilename returns correct filename", () => {
const filename = getLibFilename();
const ext = getLibExtension();
if (process.platform === "win32") {
expect(filename).toBe(`fff_c.${ext}`);
} else {
expect(filename).toBe(`libfff_c.${ext}`);
}
});
});
describe("Binary Detection", () => {
test("findBinary returns a path", () => {
const path = findBinary();
expect(path).not.toBeNull();
});
});
describe("FileFinder - Health Check", () => {
test("healthCheckStatic works without an instance", () => {
const result = FileFinder.healthCheckStatic();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.version).toBeDefined();
expect(result.value.git.available).toBe(true);
expect(result.value.filePicker.initialized).toBe(false);
}
});
});
describe("FileFinder - Full Lifecycle", () => {
let finder: FileFinder;
beforeAll(() => {
const result = FileFinder.create({ basePath: testDir });
expect(result.ok).toBe(true);
if (result.ok) {
finder = result.value;
}
});
afterAll(() => {
finder?.destroy();
});
test("create succeeds with valid path", () => {
expect(finder).toBeDefined();
expect(finder.isDestroyed).toBe(false);
});
test("isScanning returns a boolean", () => {
const scanning = finder.isScanning();
expect(typeof scanning).toBe("boolean");
});
test("getScanProgress returns valid data", () => {
const result = finder.getScanProgress();
expect(result.ok).toBe(true);
if (result.ok) {
expect(typeof result.value.scannedFilesCount).toBe("number");
expect(typeof result.value.isScanning).toBe("boolean");
}
});
test("waitForScanBlocking completes", () => {
// Small timeout - scan should be fast or already done
const result = finder.waitForScanBlocking(500);
expect(result.ok).toBe(true);
});
test("search with empty query returns all files", () => {
// First check scan progress to see if files were indexed
const progress = finder.getScanProgress();
if (progress.ok) {
}
const result = finder.fileSearch("");
expect(result.ok).toBe(true);
if (result.ok) {
if (result.value.items.length > 0) {
// Log first few paths to see format on Windows
// Items are strings (file paths), not objects
const _samplePaths = result.value.items
.slice(0, 3)
.map((item) =>
normalizePath(typeof item === "string" ? item : item.relativePath),
);
}
// Empty query should return files (frecency-sorted)
expect(result.value.totalFiles).toBeGreaterThan(0);
} else {
}
});
test("search returns a valid result structure", () => {
const result = finder.fileSearch("Cargo.toml");
expect(result.ok).toBe(true);
if (result.ok) {
expect(typeof result.value.totalMatched).toBe("number");
expect(typeof result.value.totalFiles).toBe("number");
expect(Array.isArray(result.value.items)).toBe(true);
expect(Array.isArray(result.value.scores)).toBe(true);
}
});
test("search returns empty for non-matching query", () => {
const result = finder.fileSearch("xyznonexistentfilenamexyz123456");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.totalMatched).toBe(0);
expect(result.value.items.length).toBe(0);
}
});
test("search respects pageSize option", () => {
const result = finder.fileSearch("ts", { pageSize: 3 });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.items.length).toBeLessThanOrEqual(3);
}
});
test("glob filters by extension via raw pattern", () => {
const result = finder.glob("**/*.ts", { pageSize: 50 });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.items.length).toBeGreaterThan(0);
for (const item of result.value.items) {
expect(item.relativePath.endsWith(".ts")).toBe(true);
}
}
});
test("glob returns empty result for non-matching pattern", () => {
const result = finder.glob("**/this-extension-does-not-exist-anywhere.zzz");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.items.length).toBe(0);
}
});
test("glob rejects empty pattern", () => {
const result = finder.glob("");
expect(result.ok).toBe(false);
});
test("glob respects pageSize", () => {
const result = finder.glob("**/*.ts", { pageSize: 2 });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.items.length).toBeLessThanOrEqual(2);
}
});
test("glob pageIndex offsets results", () => {
// pageIndex is a raw item offset (not a page-count multiplier). Verify
// by skipping the first item and checking the second result begins
// where page0[1] left off.
const page0 = finder.glob("**/*.ts", { pageSize: 5, pageIndex: 0 });
const page1 = finder.glob("**/*.ts", { pageSize: 5, pageIndex: 1 });
expect(page0.ok).toBe(true);
expect(page1.ok).toBe(true);
if (
page0.ok &&
page1.ok &&
page0.value.items.length > 1 &&
page1.value.items.length > 0
) {
expect(page1.value.items[0]!.relativePath).toBe(page0.value.items[1]!.relativePath);
}
});
test("glob directory-prefix pattern matches only that subtree", () => {
const result = finder.glob("src/**/*.ts", { pageSize: 100 });
expect(result.ok).toBe(true);
if (result.ok) {
for (const item of result.value.items) {
expect(item.relativePath.startsWith("src/")).toBe(true);
expect(item.relativePath.endsWith(".ts")).toBe(true);
}
}
});
test("glob result items carry expected fields", () => {
const result = finder.glob("**/*.ts", { pageSize: 1 });
expect(result.ok).toBe(true);
if (result.ok && result.value.items.length > 0) {
const item = result.value.items[0];
expect(typeof item.relativePath).toBe("string");
expect(typeof item.fileName).toBe("string");
expect(item.relativePath.length).toBeGreaterThan(0);
}
});
test("glob literal extension pattern (no leading **) still filters", () => {
const result = finder.glob("*.ts", { pageSize: 100 });
expect(result.ok).toBe(true);
// Don't assert non-zero — depends on whether top-level .ts files exist.
// Just assert all returned items match.
if (result.ok) {
for (const item of result.value.items) {
expect(item.relativePath.endsWith(".ts")).toBe(true);
}
}
});
test("grep plain text returns matching lines", () => {
const result = finder.grep("fff-core", {
mode: "plain",
});
expect(result.ok).toBe(true);
if (result.ok) {
if (result.value.items.length > 0) {
// Log sample match to verify content on Windows
const first = result.value.items[0];
const _normalizedPath = normalizePath(first.relativePath);
}
expect(result.value.totalMatched).toBeGreaterThan(0);
expect(result.value.items.length).toBeGreaterThan(0);
const first = result.value.items[0];
expect(typeof first.relativePath).toBe("string");
// Normalize path for cross-platform validation
const normalizedFirstPath = normalizePath(first.relativePath);
expect(normalizedFirstPath).toBeTruthy();
expect(typeof first.lineNumber).toBe("number");
expect(first.lineNumber).toBeGreaterThan(0);
expect(typeof first.lineContent).toBe("string");
expect(first.lineContent.toLowerCase()).toContain("fff-core");
expect(Array.isArray(first.matchRanges)).toBe(true);
expect(first.matchRanges.length).toBeGreaterThan(0);
expect(typeof result.value.totalFilesSearched).toBe("number");
expect(typeof result.value.totalFiles).toBe("number");
expect(typeof result.value.filteredFileCount).toBe("number");
} else {
}
});
test("grep respects pageSize option", () => {
// Cap to one match per file so pageSize bounds the total deterministically.
const unbounded = finder.grep("import", {
mode: "plain",
maxMatchesPerFile: 1,
});
expect(unbounded.ok).toBe(true);
if (!unbounded.ok) return;
expect(unbounded.value.items.length).toBeGreaterThan(2);
const limited = finder.grep("import", {
mode: "plain",
maxMatchesPerFile: 1,
pageSize: 2,
});
expect(limited.ok).toBe(true);
if (!limited.ok) return;
expect(limited.value.items.length).toBeLessThanOrEqual(2);
expect(limited.value.items.length).toBeLessThan(unbounded.value.items.length);
expect(limited.value.nextCursor).not.toBeNull();
});
test("grep fuzzy mode returns results with scores", () => {
// Intentional typo: "depdnency" instead of "dependency" to exercise fuzzy matching
const result = finder.grep("depdnency", {
mode: "fuzzy",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.totalMatched).toBeGreaterThan(0);
expect(result.value.items.length).toBeGreaterThan(0);
const first = result.value.items[0];
expect(typeof first.relativePath).toBe("string");
// Normalize path for cross-platform validation
const normalizedFirstPath = normalizePath(first.relativePath);
expect(normalizedFirstPath).toBeTruthy();
expect(typeof first.lineNumber).toBe("number");
expect(typeof first.lineContent).toBe("string");
// Fuzzy mode should produce a fuzzyScore on each match
expect(typeof first.fuzzyScore).toBe("number");
}
});
test("healthCheck shows initialized state", () => {
const result = finder.healthCheck();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.filePicker.initialized).toBe(true);
expect(result.value.filePicker.basePath).toBeDefined();
// Normalize basePath for cross-platform comparison
const normalizedBasePath = normalizePath(result.value.filePicker.basePath || "");
const normalizedTestDir = normalizePath(testDir);
expect(normalizedBasePath).toBe(normalizedTestDir);
expect(typeof result.value.filePicker.indexedFiles).toBe("number");
}
});
test("healthCheck detects git repository", () => {
const result = finder.healthCheck(testDir);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.git.available).toBe(true);
expect(typeof result.value.git.repositoryFound).toBe("boolean");
}
});
test("destroy and re-create works", () => {
finder.destroy();
expect(finder.isDestroyed).toBe(true);
const result = FileFinder.create({ basePath: testDir });
expect(result.ok).toBe(true);
if (result.ok) {
finder = result.value;
}
expect(finder.isDestroyed).toBe(false);
});
test("multiple instances can coexist", () => {
const result2 = FileFinder.create({ basePath: testDir });
expect(result2.ok).toBe(true);
if (result2.ok) {
const finder2 = result2.value;
// Both should work independently
const search1 = finder.fileSearch("Cargo");
const search2 = finder2.fileSearch("Cargo");
expect(search1.ok).toBe(true);
expect(search2.ok).toBe(true);
// Destroying one should not affect the other
finder2.destroy();
const search3 = finder.fileSearch("Cargo");
expect(search3.ok).toBe(true);
}
});
});
describe("FileFinder - Directory Search", () => {
let finder: FileFinder;
const tmpDir = path.join(testDir, "__test_dirs__");
const sep = path.sep;
beforeAll(() => {
fs.mkdirSync(path.join(tmpDir, "alpha", "nested"), { recursive: true });
fs.mkdirSync(path.join(tmpDir, "beta"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "alpha", "file.txt"), "x");
fs.writeFileSync(path.join(tmpDir, "alpha", "nested", "deep.txt"), "x");
fs.writeFileSync(path.join(tmpDir, "beta", "file.txt"), "x");
const result = FileFinder.create({ basePath: testDir });
expect(result.ok).toBe(true);
if (result.ok) {
finder = result.value;
}
finder.waitForScanBlocking(5000);
});
afterAll(() => {
finder?.destroy();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("known directories are returned with correct paths", () => {
const result = finder.directorySearch("__test_dirs__");
expect(result.ok).toBe(true);
if (!result.ok) return;
const paths = result.value.items.map((i) => i.relativePath);
expect(paths).toContain(`__test_dirs__${sep}alpha${sep}`);
expect(paths).toContain(`__test_dirs__${sep}beta${sep}`);
expect(paths).toContain(`__test_dirs__${sep}alpha${sep}nested${sep}`);
});
test("nested directory uses native separators and correct dirName", () => {
const result = finder.directorySearch("nested");
expect(result.ok).toBe(true);
if (!result.ok) return;
const nested = result.value.items.find((i) => i.relativePath.includes("nested"));
expect(nested).toBeDefined();
expect(nested!.relativePath).toBe(`__test_dirs__${sep}alpha${sep}nested${sep}`);
expect(nested!.dirName).toBe(`nested${sep}`);
});
});
describe("FileFinder - Error Handling", () => {
test("search fails on destroyed instance", () => {
const createResult = FileFinder.create({ basePath: testDir });
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
const f = createResult.value;
f.destroy();
const result = f.fileSearch("test");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("destroyed");
}
});
test("getScanProgress fails on destroyed instance", () => {
const createResult = FileFinder.create({ basePath: testDir });
expect(createResult.ok).toBe(true);
if (!createResult.ok) return;
const f = createResult.value;
f.destroy();
const result = f.getScanProgress();
expect(result.ok).toBe(false);
});
test("create fails with invalid path", () => {
// Use a cross-platform invalid path
const invalidPath =
process.platform === "win32"
? "C:\\nonexistent\\path\\that\\does\\not\\exist"
: "/nonexistent/path/that/does/not/exist";
const result = FileFinder.create({
basePath: invalidPath,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("Failed");
}
});
});
describe("Result Type Helpers", () => {
test("ok helper creates success result", async () => {
const { ok } = await import("../src/fff-api");
const result = ok(42);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe(42);
}
});
test("err helper creates error result", async () => {
const { err } = await import("../src/fff-api");
const result = err<number>("something went wrong");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe("something went wrong");
}
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"lib": ["ESNext"],
"types": ["bun"]
},
"include": ["src/**/*", "*.ts", "test/git-lifecycle.test.ts", "test/index.test.ts"],
"exclude": ["node_modules", "dist", "bin"]
}
+126
View File
@@ -0,0 +1,126 @@
# fff - Fast File Finder
High-performance fuzzy file finder for Node.js, powered by Rust. Extremely fast live file, content, and directory search with a typo-resistant algorithm. As well as regex, plain-text, multi-occurrence and typo-resistant content search.
Comes with built-in git status support, frecency access tracking, and a real-time file watcher, content indexing and many more! Designed for LLM agent tools that search through codebases or agentic RAG document search.
Faster than ripgrep & fzf on any workflow that runs more than once per process.
## Installation
```bash
npm install @ff-labs/fff-node
```
The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bin-darwin-arm64`, `@ff-labs/fff-bin-linux-x64-gnu`)
### Supported Platforms
| Platform | Architecture | Package |
| -------- | --------------------- | ----------------------------------- |
| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bin-darwin-arm64` |
| macOS | x64 (Intel) | `@ff-labs/fff-bin-darwin-x64` |
| Linux | x64 (glibc) | `@ff-labs/fff-bin-linux-x64-gnu` |
| Linux | ARM64 (glibc) | `@ff-labs/fff-bin-linux-arm64-gnu` |
| Linux | x64 (musl) | `@ff-labs/fff-bin-linux-x64-musl` |
| Linux | ARM64 (musl) | `@ff-labs/fff-bin-linux-arm64-musl` |
| Windows | x64 | `@ff-labs/fff-bin-win32-x64` |
| Windows | ARM64 | `@ff-labs/fff-bin-win32-arm64` |
If the platform package isn't available, the postinstall script will attempt to download from GitHub releases as a fallback.
## Quick Start
Each `FileFinder` instance owns an independent native index. Create one, wait
for the initial scan, then run as many searches as you like.
```typescript
import { FileFinder } from "@ff-labs/fff-node";
// Create an instance bound to a directory
const created = FileFinder.create({ basePath: "/path/to/project" });
if (!created.ok) throw new Error(created.error);
const finder = created.value;
// Wait for the initial scan (async, non-blocking)
await finder.waitForScan(5000);
// 1. Fuzzy file search (typo resistant)
const files = finder.fileSearch("typescropt.ts", { pageSize: 10 });
if (files.ok) {
for (const item of files.value.items) {
console.log(item.relativePath, item.gitStatus);
}
}
// 2. Glob filter — no fuzzy matching, 100% compatible with npm `glob`
const globbed = finder.glob("src/**/*.ts");
if (globbed.ok) console.log(`${globbed.value.totalMatched} TypeScript files`);
// 3. Content search (live grep) with pagination
const grep = finder.grep("TODO", { mode: "plain", pageSize: 20 });
if (grep.ok) {
for (const m of grep.value.items) {
console.log(`${m.relativePath}:${m.lineNumber}: ${m.lineContent}`);
}
}
// 4. Directory search based on the query (typo resistant)
const dirs = finder.directorySearch("components");
if (dirs.ok) console.log(dirs.value.items.map((d) => d.relativePath));
// Free the resources when you don't need a file picker anymore
finder.destroy();
```
## API Reference
Verify the latest API in the local interface at [`./src/fff-api.ts`](./src/fff-api.ts). Every field and type is documented.
### Result Types
All methods return a `Result<T>` type for explicit error handling:
```typescript
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
const result = finder.fileSearch("foo");
if (result.ok) {
// result.value is SearchResult
} else {
// result.error is string error message
}
```
This SDK calls a native compiled library for your platform at runtime. This is generally safe — fff is battle-tested and stable, and written in a memory-safe language — but there is a class of errors that can't be caught at the Node.js level. If you hit one, please report an issue!
## Building from Source
If prebuilt binaries aren't available for your platform:
```bash
# Clone the repository
git clone https://github.com/dmtrKovalenko/fff.nvim
cd fff.nvim
# Build the C library
cargo build --release -p fff-c
# The binary will be at target/release/libfff_c.{so,dylib,dll}
```
## CLI examples
```bash
# Download binary manually (fallback if npm package unavailable)
npx @ff-labs/fff-node download [tag]
# Show platform info and binary location
npx @ff-labs/fff-node info
```
## License
MIT
+76
View File
@@ -0,0 +1,76 @@
{
"name": "@ff-labs/fff-node",
"version": "0.1.37",
"private": false,
"description": "High-performance fuzzy file finder for Node.js - perfect for LLM agent tools",
"type": "module",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"exports": {
".": {
"import": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node test/e2e.mjs",
"typecheck": "tsc --noEmit"
},
"engines": {
"node": ">=18.0.0"
},
"os": [
"darwin",
"linux",
"win32"
],
"cpu": [
"x64",
"arm64"
],
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/fff-node"
},
"keywords": [
"file-finder",
"fuzzy-search",
"node",
"ffi",
"llm-tools",
"agent-tools",
"fast",
"rust"
],
"author": "Dmitry Kovalenko",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/dmtrKovalenko/fff/issues"
},
"homepage": "https://github.com/dmtrKovalenko/fff#readme",
"dependencies": {
"ffi-rs": "^1.0.0"
},
"optionalDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.0.0",
"@ff-labs/fff-bin-darwin-x64": "0.0.0",
"@ff-labs/fff-bin-linux-x64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.0.0",
"@ff-labs/fff-bin-linux-x64-musl": "0.0.0",
"@ff-labs/fff-bin-linux-arm64-musl": "0.0.0",
"@ff-labs/fff-bin-win32-x64": "0.0.0",
"@ff-labs/fff-bin-win32-arm64": "0.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/node": "^22.0.0"
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* CLI tool for fff-node package management
*
* Usage:
* npx @ff-labs/fff-node download [tag] - Download native binary from GitHub
* npx @ff-labs/fff-node info - Show platform and binary info
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { downloadBinary, findBinary, getBinaryPath } from "../src/binary.js";
import {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "../src/platform.js";
const args = process.argv.slice(2);
const command = args[0];
interface PackageJson {
version: string;
}
function getPackageInfo(): PackageJson {
const currentDir = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = join(currentDir, "..", "package.json");
try {
return JSON.parse(readFileSync(packageJsonPath, "utf-8"));
} catch {
return { version: "unknown" };
}
}
async function main() {
switch (command) {
case "download": {
const tag = args[1];
console.log("fff: Downloading native library from GitHub...");
try {
const resolvedTag = await downloadBinary(tag);
console.log(`fff: Download complete! (${resolvedTag})`);
} catch (error) {
console.error("fff: Download failed:", error);
process.exit(1);
}
break;
}
case "info": {
const pkg = getPackageInfo();
let npmPackage: string;
try {
npmPackage = getNpmPackageName();
} catch {
npmPackage = "unsupported";
}
console.log("fff - Fast File Finder (Node.js)");
console.log(`Package version: ${pkg.version}`);
console.log("");
console.log("Platform Information:");
console.log(` Triple: ${getTriple()}`);
console.log(` Extension: ${getLibExtension()}`);
console.log(` Library name: ${getLibFilename()}`);
console.log(` npm package: ${npmPackage}`);
console.log("");
console.log("Binary Status:");
const existing = findBinary();
if (existing) {
console.log(` Found: ${existing}`);
} else {
console.log(" Not found");
console.log(` Expected path: ${getBinaryPath()}`);
console.log(` Try: npm add ${npmPackage}`);
}
break;
}
case "version":
case "--version":
case "-v": {
const pkg = getPackageInfo();
console.log(pkg.version);
break;
}
default: {
const pkg = getPackageInfo();
console.log(`fff - Fast File Finder CLI (Node.js) v${pkg.version}`);
console.log("");
console.log("Usage:");
console.log(
" npx @ff-labs/fff-node download [tag] Download native binary from GitHub (fallback)",
);
console.log(
" npx @ff-labs/fff-node info Show platform and binary info",
);
console.log(" npx @ff-labs/fff-node version Show version");
console.log(" npx @ff-labs/fff-node help Show this help message");
console.log("");
console.log("Examples:");
console.log(
" npx @ff-labs/fff-node download Download latest binary from GitHub",
);
console.log(
" npx @ff-labs/fff-node download abc1234 Download specific release tag",
);
console.log("");
console.log(
"Note: Binaries are normally provided via platform-specific npm packages.",
);
console.log("The download command is a fallback for when those aren't available.");
break;
}
}
}
main();
+50
View File
@@ -0,0 +1,50 @@
/**
* Postinstall script - ensures the native binary is available
*
* Resolution order:
* 1. Platform-specific npm package (installed via optionalDependencies)
* 2. Local dev build (target/release or target/debug)
* 3. Fallback: download from GitHub releases
*/
import { downloadBinary, findBinary } from "../src/binary.js";
import { getNpmPackageName } from "../src/platform.js";
async function main() {
// Check if binary is already available (npm package or dev build)
const existing = findBinary();
if (existing) {
console.log(`fff: Native library found at ${existing}`);
return;
}
// Binary not found via npm package - try downloading from GitHub as fallback
let packageName: string;
try {
packageName = getNpmPackageName();
} catch {
packageName = "unknown";
}
console.log(
`fff: Platform package ${packageName} not found, falling back to GitHub download...`,
);
try {
const tag = await downloadBinary();
console.log(`fff: Native library installed successfully! (${tag})`);
} catch (error) {
console.error("fff: Failed to download native library:", error);
console.error("");
console.error("fff: You can build from source instead:");
console.error(" cargo build --release -p fff-c");
console.error("");
console.error(
"fff: Or run `npx @ff-labs/fff-node download` after fixing network issues.",
);
// Don't exit with error - allow install to complete
// The error will surface when the user tries to use the library
}
}
main();
+148
View File
@@ -0,0 +1,148 @@
/**
* Binary resolution utilities for fff-node
*
* Resolves the native library from:
* 1. Platform-specific npm package (e.g. @ff-labs/fff-bin-darwin-arm64)
* 2. Local dev build (target/release or target/debug)
*/
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getLibFilename, getNpmPackageName } from "./platform.js";
/**
* Get the current file's directory
*/
function getCurrentDir(): string {
const url = import.meta.url;
if (url.startsWith("file://")) {
return dirname(fileURLToPath(url));
}
return dirname(url);
}
/**
* Get the package root directory
*/
function getPackageDir(): string {
const currentDir = getCurrentDir();
// In dev: src/ -> package root
// In dist: dist/src/ -> package root
// We look for package.json to find the actual root
let dir = currentDir;
for (let i = 0; i < 5; i++) {
if (existsSync(join(dir, "package.json"))) {
try {
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
if (pkg.name === "@ff-labs/fff-node") {
return dir;
}
} catch {
// Not our package.json, keep going up
}
}
dir = dirname(dir);
}
// Fallback: assume we're one level deep in src/
return dirname(currentDir);
}
/**
* Check if the binary exists in any known location
*/
export function binaryExists(): boolean {
return findBinary() !== null;
}
/**
* Try to resolve the binary from the platform-specific npm package.
*
* When users install @ff-labs/fff-node, npm automatically installs the matching
* optionalDependency (e.g. @ff-labs/fff-bin-darwin-arm64). We resolve the binary
* path by requiring that package's package.json and looking for the binary
* in the same directory.
*/
function resolveFromNpmPackage(): string | null {
const packageName = getNpmPackageName();
try {
// Use createRequire to resolve the platform package's location
const require = createRequire(join(getPackageDir(), "package.json"));
const packageJsonPath = require.resolve(`${packageName}/package.json`);
const packageDir = dirname(packageJsonPath);
const binaryPath = join(packageDir, getLibFilename());
if (existsSync(binaryPath)) {
return binaryPath;
}
} catch {
// Package not installed - this is expected on unsupported platforms
// or when installed without optional dependencies
}
return null;
}
/**
* Get the development binary path (for local development)
*/
function getDevBinaryPath(): string | null {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
const possiblePaths = [
join(workspaceRoot, "target", "release", getLibFilename()),
join(workspaceRoot, "target", "debug", getLibFilename()),
];
for (const path of possiblePaths) {
if (existsSync(path)) {
return path;
}
}
return null;
}
function isDevWorkspace(): boolean {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
return existsSync(join(workspaceRoot, "Cargo.toml"));
}
/**
* Find the native library binary.
*
* Resolution order:
* - Dev workspace: local dev build first, then npm package
* - Production: npm package first, then dev build
*
* @returns Absolute path to the library, or null if not found
*/
export function findBinary(): string | null {
if (isDevWorkspace()) {
// 1. Local bin/ directory (populated by `make prepare-node`)
const binPath = join(getPackageDir(), "bin", getLibFilename());
if (existsSync(binPath)) return binPath;
// 2. Local dev build (target/release or target/debug)
const devPath = getDevBinaryPath();
if (devPath) return devPath;
// 3. Fallback to npm package
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
return null;
}
// Production: npm package first
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
// Fallback: local dev build (e.g. user built from source)
return getDevBinaryPath();
}
+609
View File
@@ -0,0 +1,609 @@
// ----------------------------------------------------------------------------
// GENERATED FILE - DO NOT EDIT.
// Copied from: packages/shared/fff-api.ts
// Run make sync-js-api from the repo root to regenerate.
// ----------------------------------------------------------------------------
/**
* The shared public API surface for the fff file finder, implemented identically
* by `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* This file is the single source of truth for every type, helper, and the
* `FileFinderApi` interface that crosses the package boundary. It is copied
* verbatim into each package's `src/fff-api.ts` by `make sync-api`.
*
* Anything that is not part of the public API (FFI struct layouts, binary
* loading, platform detection, etc.) stays as per-package internal
* implementation and must NOT live here.
*
* Keep this file self-contained: it must not import from any package-local
* module, since each package compiles its own copy.
*/
/**
* Result type for all operations - follows the Result pattern
*/
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
/**
* Helper to create a successful result
*/
export function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
/**
* Helper to create an error result
*/
export function err<T>(error: string): Result<T> {
return { ok: false, error };
}
/**
* Initialization options for the file finder
*/
export interface InitOptions {
/** Base directory to index (required) */
basePath: string;
/** Path to frecency database (optional, omit to skip frecency initialization) */
frecencyDbPath?: string;
/** Path to query history database (optional, omit to skip query tracker initialization) */
historyDbPath?: string;
/** @deprecated no-op */
useUnsafeNoLock?: boolean;
/**
* Disable mmap cache warmup after the initial scan. When mmap cache is
* enabled (the default), the first grep search is as fast as subsequent
* ones at the cost of a longer scan time and higher initial memory usage.
*/
disableMmapCache?: boolean;
/**
* Disable the content index built after the initial scan.
* Content indexing enables faster content-aware filtering during grep.
*/
disableContentIndexing?: boolean;
/**
* Disable the background file-system watcher. When the watcher is
* disabled, files are scanned once but not monitored for changes.
* (default: false)
*/
disableWatch?: boolean;
/** enables optimizations for AI agent assistants. Provide as true if running via mcp/agent */
aiMode?: boolean;
/**
* Path to the tracing log file. When set, the shared FFF tracing subscriber
* is installed on first init and file output is written here. Omit to leave
* logging uninitialized.
*/
logFilePath?: string;
/**
* Log level for the tracing subscriber: "trace", "debug", "info", "warn",
* or "error". Defaults to "info". Ignored when `logFilePath` is not set.
*/
logLevel?: "trace" | "debug" | "info" | "warn" | "error";
/**
* Override for the content cache file-count cap. When omitted, the picker
* auto-sizes the budget from the final scanned file count.
*/
cacheBudgetMaxFiles?: number;
/** Override for the content cache byte cap. See `cacheBudgetMaxFiles`. */
cacheBudgetMaxBytes?: number;
/** Override for the per-file byte cap in the content cache. */
cacheBudgetMaxFileSize?: number;
/**
* Allow indexing the filesystem root (`/`).
* Off by default, having fff instance at the large folder will generally require
* file watcher and indexing which will consume a lot of resources if performed uncontrolled
**/
enableFsRootScanning?: boolean;
/** Allow indexing the user's home directory. Same trade-off as `enableFsRootScanning`. */
enableHomeDirScanning?: boolean;
/** Follow symlinks for directories */
followSymlinks?: boolean;
}
/**
* Search options for fuzzy file search
*/
export interface SearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for deprioritization in results) */
currentFile?: string;
/** Combo boost score multiplier (default: 100) */
comboBoostMultiplier?: number;
/** Minimum combo count for boost (default: 3) */
minComboCount?: number;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Options for `glob`, the constraint-only search.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*/
export interface GlobOptions {
/** Maximum threads for parallel filtering (0 = auto). */
maxThreads?: number;
/** Current file path (for deprioritization in results). */
currentFile?: string;
/** Page index for pagination (default: 0). */
pageIndex?: number;
/** Page size for pagination (default: 100). */
pageSize?: number;
}
/**
* A file item in search results
*/
export interface FileItem {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Frecency score based on access patterns */
accessFrecencyScore: number;
/** Frecency score based on modification time */
modificationFrecencyScore: number;
/** Combined frecency score */
totalFrecencyScore: number;
/** Git status: 'clean', 'modified', 'untracked', 'staged_new', etc. */
gitStatus: string;
}
/**
* Score breakdown for a search result
*/
export interface Score {
/** Total combined score */
total: number;
/** Base fuzzy match score */
baseScore: number;
/** Bonus for filename match */
filenameBonus: number;
/** Bonus for special filenames (index.ts, main.rs, etc.) */
specialFilenameBonus: number;
/** Boost from frecency */
frecencyBoost: number;
/** Penalty for distance in path */
distancePenalty: number;
/** Penalty if this is the current file */
currentFilePenalty: number;
/** Boost from query history combo matching */
comboMatchBoost: number;
/** Whether this was an exact match */
exactMatch: boolean;
/** Type of match: 'fuzzy', 'exact', 'prefix', etc. */
matchType: string;
}
/**
* Location in file (from query like "file.ts:42")
*/
export type Location =
| { type: "line"; line: number }
| { type: "position"; line: number; col: number }
| {
type: "range";
start: { line: number; col: number };
end: { line: number; col: number };
};
/**
* Search result from fuzzy file search
*/
export interface SearchResult {
/** Matched file items */
items: FileItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of files that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Location parsed from query (e.g., "file.ts:42:10") */
location?: Location;
}
/**
* A directory item in search results
*/
export interface DirItem {
/** Path relative to the indexed directory (e.g., "src/components/") */
relativePath: string;
/** Last path segment (e.g., "components/" for "src/components/") */
dirName: string;
/** Maximum access frecency score among direct child files */
maxAccessFrecency: number;
}
/**
* Search options for directory search (subset of SearchOptions)
*/
export interface DirSearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for distance scoring) */
currentFile?: string;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Search result from fuzzy directory search
*/
export interface DirSearchResult {
/** Matched directory items */
items: DirItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of directories that matched */
totalMatched: number;
/** Total number of indexed directories */
totalDirs: number;
}
/**
* A single item in a mixed (files + directories) search result
*/
export type MixedItem =
| { type: "file"; item: FileItem }
| { type: "directory"; item: DirItem };
/**
* Search result from mixed (files + directories) fuzzy search.
* Items are interleaved by total score in descending order.
*/
export interface MixedSearchResult {
/** Matched items (files and directories interleaved by score) */
items: MixedItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of items (files + dirs) that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Total number of indexed directories */
totalDirs: number;
/** Location parsed from query */
location?: Location;
}
/**
* Scan progress information
*/
export interface ScanProgress {
/** Number of files scanned so far */
scannedFilesCount: number;
/** Whether a scan is currently in progress */
isScanning: boolean;
/** Whether the background file watcher is ready */
isWatcherReady: boolean;
/** Whether the warmup/bigram phase has completed */
isWarmupComplete: boolean;
}
/**
* Database health information
*/
export interface DbHealth {
/** Path to the database */
path: string;
/** Size of the database on disk in bytes */
diskSize: number;
}
/**
* Health check result
*/
export interface HealthCheck {
/** Library version */
version: string;
/** Git integration status */
git: {
/** Whether git2 library is available */
available: boolean;
/** Whether a git repository was found */
repositoryFound: boolean;
/** Git working directory path */
workdir?: string;
/** libgit2 version string */
libgit2Version: string;
/** Error message if git detection failed */
error?: string;
};
/** File picker status */
filePicker: {
/** Whether the file picker is initialized */
initialized: boolean;
/** Base path being indexed */
basePath?: string;
/** Whether a scan is in progress */
isScanning?: boolean;
/** Number of indexed files */
indexedFiles?: number;
/** Error message if there's an issue */
error?: string;
};
/** Frecency database status */
frecency: {
/** Whether frecency tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
/** Query tracker status */
queryTracker: {
/** Whether query tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
}
/**
* Grep search mode
*/
export type GrepMode = "plain" | "regex" | "fuzzy";
/**
* Opaque pagination cursor for grep results.
* Pass this to `GrepOptions.cursor` to fetch the next page.
* Do not construct or modify this — use the `nextCursor` from a previous `GrepResult`.
*/
export interface GrepCursor {
/** @internal */
readonly __brand: "GrepCursor";
/** @internal */
readonly _offset: number;
}
/**
* @internal Create a GrepCursor from a raw file offset.
*/
export function createGrepCursor(offset: number): GrepCursor {
return { __brand: "GrepCursor" as const, _offset: offset };
}
/**
* Options for live grep (content search)
*
* Files are searched sequentially in frecency order (most recently/frequently
* accessed first). The engine returns a `nextCursor` for fetching the next page.
*/
export interface GrepOptions {
/** Maximum file size to search in bytes. Files larger than this are skipped. (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 200) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when the query is all lowercase, case-sensitive otherwise (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/** Search mode (default: "plain") */
mode?: GrepMode;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. Let callers re-rank defs first
* without a TS-side regex port. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* A single grep match with file and line information
*/
export interface GrepMatch {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** Git status */
gitStatus: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Whether the file is binary */
isBinary: boolean;
/** Combined frecency score */
totalFrecencyScore: number;
/** Access-based frecency score */
accessFrecencyScore: number;
/** Modification-based frecency score */
modificationFrecencyScore: number;
/** 1-based line number of the match */
lineNumber: number;
/** 0-based byte column of first match start */
col: number;
/** Absolute byte offset of the matched line from file start */
byteOffset: number;
/** The matched line text (may be truncated) */
lineContent: string;
/** Byte offset pairs [start, end] within lineContent for highlighting */
matchRanges: [number, number][];
/** Fuzzy match score (only in fuzzy mode) */
fuzzyScore?: number;
/** Lines before the match (context). Empty array when context is 0. */
contextBefore?: string[];
/** Lines after the match (context). Empty array when context is 0. */
contextAfter?: string[];
/** Whether this line is a code definition (only populated when `classifyDefinitions: true`). */
isDefinition?: boolean;
}
/**
* Result from a grep search
*/
export interface GrepResult {
/** Matched items with file and line information. At most `max_matches_per_file`. */
items: GrepMatch[];
/** Total number of matches collected (always equal to items.length). */
totalMatched: number;
/** Number of files actually opened and searched in this call */
totalFilesSearched: number;
/** Total number of indexed files (before any filtering) */
totalFiles: number;
/** Number of files eligible for search after filtering out binary files, oversized files, and constraint mismatches */
filteredFileCount: number;
/**
* Cursor for the next page, or `null` if all eligible files have been searched.
* Pass this as `GrepOptions.cursor` to continue from where this call left off.
*/
nextCursor: GrepCursor | null;
/** When regex mode fails to compile the pattern, the engine falls back to literal matching and this field contains the compilation error */
regexFallbackError?: string;
}
/**
* Options for multi-pattern grep (Aho-Corasick multi-needle search)
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated Aho-Corasick multi-pattern matching.
*/
export interface MultiGrepOptions {
/** Patterns to search for (OR logic — matches lines containing any pattern) */
patterns: string[];
/** File constraints like "*.rs" or "/src/" */
constraints?: string;
/** Maximum file size to search in bytes (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 0 = unlimited) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when all patterns are lowercase (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* The shared instance surface implemented by `FileFinder` in both
* `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* Both packages must implement this identically. Only instance members belong
* here. Static helpers (`create`, `isAvailable`, `ensureLoaded`,
* `healthCheckStatic`) are package-specific and intentionally excluded.
*/
export interface FileFinderApi {
/** Whether the instance has been destroyed. */
readonly isDestroyed: boolean;
/** Destroy and free all native resources. */
destroy(): void;
/** Fuzzy file search. */
fileSearch(query: string, options?: SearchOptions): Result<SearchResult>;
/** Glob-only filtering (no fuzzy matching). */
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
/** Fuzzy directory search. */
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
/** Fuzzy search over files and directories interleaved by score. */
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
/** Content search (live grep). */
grep(query: string, options?: GrepOptions): Result<GrepResult>;
/** Multi-pattern OR content search (Aho-Corasick). */
multiGrep(options: MultiGrepOptions): Result<GrepResult>;
/** Trigger an async rescan of the indexed directory. */
scanFiles(): Result<void>;
/** Whether a scan is currently in progress. */
isScanning(): boolean;
/** The root directory being indexed. */
getBasePath(): Result<string | null>;
/** Current scan progress snapshot. */
getScanProgress(): Result<ScanProgress>;
/**
* Wait for the initial file scan to complete.
*
* Non-blocking: polls `isScanning` and yields to the event loop between
* checks, so other async work keeps running while waiting.
*/
waitForScan(timeoutMs?: number): Promise<Result<boolean>>;
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer `waitForScan` unless
* you specifically need synchronous blocking behaviour.
*/
waitForScanBlocking(timeoutMs?: number): Result<boolean>;
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*/
waitForIndexReady(timeoutMs?: number): Promise<Result<boolean>>;
/** Restart indexing in a new directory. */
reindex(newPath: string): Result<void>;
/** Refresh the git status cache. Returns the number of updated files. */
refreshGitStatus(): Result<number>;
/** Record that `selectedFilePath` was chosen for `query`. */
trackQuery(query: string, selectedFilePath: string): Result<boolean>;
/** Get a historical query by offset (0 = most recent). */
getHistoricalQuery(offset: number): Result<string | null>;
/** Health/diagnostics information for this instance. */
healthCheck(testPath?: string): Result<HealthCheck>;
}
File diff suppressed because it is too large Load Diff
+623
View File
@@ -0,0 +1,623 @@
/**
* FileFinder - High-level API for the fff file finder
*
* This class provides a type-safe, ergonomic API for file finding operations.
* Each instance owns an independent native file picker that can be created
* and destroyed independently. Multiple instances can coexist.
*
* All methods return Result types for explicit error handling.
*/
import {
ensureLoaded,
ffiCreate,
ffiDestroy,
ffiGetBasePath,
ffiGetHistoricalQuery,
ffiGetScanProgress,
ffiGlob,
ffiHealthCheck,
ffiIsScanning,
ffiLiveGrep,
ffiMultiGrep,
ffiRefreshGitStatus,
ffiRestartIndex,
ffiScanFiles,
ffiSearch,
ffiSearchDirectories,
ffiSearchMixed,
ffiTrackQuery,
ffiWaitForScan,
isAvailable,
type NativeHandle,
} from "./ffi.js";
import type {
DirSearchOptions,
DirSearchResult,
FileFinderApi,
GlobOptions,
GrepOptions,
GrepResult,
HealthCheck,
InitOptions,
MixedSearchResult,
MultiGrepOptions,
Result,
ScanProgress,
SearchOptions,
SearchResult,
} from "./fff-api.js";
import { err } from "./fff-api.js";
/**
* FileFinder - Fast file finder with fuzzy search
*
* Each instance is backed by an independent native file picker. Create as many
* as you need and destroy them when done.
*
* @example
*
* ```ts
* import { FileFinder } from "@ff-labs/fff-node";
*
* // Create an instance
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (!finder.ok) {
* console.error(finder.error);
* process.exit(1);
* }
*
* // Wait for initial scan
* await finder.value.waitForScan(5000);
*
* // Search for files
* const search = finder.value.search("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
* }
* }
*
* // Cleanup
* finder.value.destroy();
* ```
*/
export class FileFinder implements FileFinderApi {
private handle: NativeHandle | null;
private constructor(handle: NativeHandle) {
this.handle = handle;
}
/**
* Create a new file finder instance.
*
* @param options - Initialization options
* @returns Result containing the new FileFinder instance or an error
*
* @example
* ```typescript
* // Basic initialization
* const finder = FileFinder.create({ basePath: "/path/to/project" });
*
* // With custom database paths
* const finder = FileFinder.create({
* basePath: "/path/to/project",
* frecencyDbPath: "/custom/frecency.mdb",
* historyDbPath: "/custom/history.mdb",
* });
* ```
*/
static create(options: InitOptions): Result<FileFinder> {
const result = ffiCreate(
options.basePath,
options.frecencyDbPath ?? "",
options.historyDbPath ?? "",
options.useUnsafeNoLock ?? false,
!(options.disableMmapCache ?? false),
!(options.disableContentIndexing ?? options.disableMmapCache ?? false),
!(options.disableWatch ?? false),
options.aiMode ?? false,
options.logFilePath ?? "",
options.logLevel ?? "",
options.cacheBudgetMaxFiles ?? 0,
options.cacheBudgetMaxBytes ?? 0,
options.cacheBudgetMaxFileSize ?? 0,
options.enableFsRootScanning ?? false,
options.enableHomeDirScanning ?? false,
options.followSymlinks ?? false,
);
if (!result.ok) {
return result;
}
return { ok: true, value: new FileFinder(result.value) };
}
/**
* Destroy and clean up all resources.
*
* Call this when you're done using the file finder to free memory
* and stop background file watching. After calling this, the instance
* must not be used again.
*/
destroy(): void {
if (this.handle !== null) {
ffiDestroy(this.handle);
this.handle = null;
}
}
/**
* Check if this instance has been destroyed.
*/
get isDestroyed(): boolean {
return this.handle === null;
}
/**
* Guard that returns an error if the instance has been destroyed.
*/
private ensureAlive(): Result<NativeHandle> {
if (this.handle === null) {
return err("FileFinder instance has been destroyed.");
}
return { ok: true, value: this.handle };
}
/**
* Search for files matching the query.
*
* The query supports fuzzy matching and special syntax:
* - `foo bar` - Match files containing "foo" and "bar"
* - `src/` - Match files in src directory
* - `file.ts:42` - Match file.ts with line 42
* - `file.ts:42:10` - Match file.ts with line 42, column 10
*
* @param query - Search query string
* @param options - Search options
* @returns Search results with matched files and scores
*
* @example
* ```typescript
* const result = finder.search("main.ts", { pageSize: 10 });
* if (result.ok) {
* console.log(`Found ${result.value.totalMatched} files`);
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
fileSearch(query: string, options?: SearchOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearch(
guard.value,
query,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
options?.comboBoostMultiplier ?? 0,
options?.minComboCount ?? 0,
);
}
/**
* Filters files using glob wildcard expression.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*
* @param pattern - Glob pattern (required, non-empty)
* @param options - Glob search options (pagination, max threads, current file)
* @returns Search results with files matching the glob
*
* @example
* ```typescript
* const result = finder.glob("**\/*.rs", { pageSize: 100 });
* if (result.ok) {
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
glob(pattern: string, options?: GlobOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGlob(
guard.value,
pattern,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
);
}
/**
* Search for directories matching the query.
*
* @param query - Search query string
* @param options - Directory search options
* @returns Search results with matched directories and scores
*
* @example
* ```typescript
* const result = finder.directorySearch("src/comp", { pageSize: 10 });
* if (result.ok) {
* console.log(`Found ${result.value.totalMatched} directories`);
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearchDirectories(
guard.value,
query,
options?.currentFile ?? null,
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
);
}
/**
* Search for files and directories matching the query (mixed results).
*
* Results are interleaved by total score in descending order, mixing
* both file and directory items.
*
* @param query - Search query string
* @param options - Search options
* @returns Mixed search results with files and directories interleaved by score
*
* @example
* ```typescript
* const result = finder.mixedSearch("main", { pageSize: 20 });
* if (result.ok) {
* for (const entry of result.value.items) {
* if (entry.type === "file") {
* console.log(`File: ${entry.item.relativePath}`);
* } else {
* console.log(`Dir: ${entry.item.relativePath}`);
* }
* }
* }
* ```
*/
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearchMixed(
guard.value,
query,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
options?.comboBoostMultiplier ?? 0,
options?.minComboCount ?? 0,
);
}
/**
* Search file contents (live grep).
*
* Searches through the contents of indexed files using the specified mode:
* - `"plain"` (default): SIMD-accelerated literal text matching
* - `"regex"`: Regular expression matching
* - `"fuzzy"`: Smith-Waterman fuzzy matching per line
*
* Supports pagination for large result sets. The result includes a `nextCursor`
* that can be passed back to fetch the next page.
*
* The query also supports constraint syntax:
* - `*.ts pattern` - Only search in TypeScript files
* - `src/ pattern` - Only search in the src directory
*
* @param query - Search query string
* @param options - Grep options (mode, pagination, limits)
* @returns Grep results with matched lines and file metadata
*
* @example
* ```typescript
* // First page
* const result = finder.grep("TODO", { mode: "plain" });
* if (result.ok) {
* for (const match of result.value.items) {
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
* }
* // Fetch next page
* if (result.value.nextCursor) {
* const page2 = finder.grep("TODO", {
* cursor: result.value.nextCursor,
* });
* }
* }
* ```
*/
grep(query: string, options?: GrepOptions): Result<GrepResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiLiveGrep(
guard.value,
query,
options?.mode ?? "plain",
options?.maxFileSize ?? 0,
options?.maxMatchesPerFile ?? 0,
options?.smartCase ?? true,
options?.cursor?._offset ?? 0,
options?.pageSize ?? 0,
options?.timeBudgetMs ?? 0,
options?.beforeContext ?? 0,
options?.afterContext ?? 0,
options?.classifyDefinitions ?? false,
);
}
/**
* Multi-pattern OR search using Aho-Corasick.
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated multi-needle matching. Faster than regex alternation
* for literal text searches.
*
* Supports pagination. The result includes a `nextCursor` that can be
* passed back to fetch the next page.
*
* @param options - Multi-grep options including patterns and optional constraints
* @returns Grep results with matched lines and file metadata
*
* @example
* ```typescript
* const result = finder.multiGrep({
* patterns: ["VideoFrame", "video_frame", "PreloadedImage"],
* });
* if (result.ok) {
* for (const match of result.value.items) {
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
* }
* }
* ```
*/
multiGrep(options: MultiGrepOptions): Result<GrepResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
if (!options.patterns || options.patterns.length === 0) {
return err("patterns array must have at least 1 element");
}
return ffiMultiGrep(
guard.value,
options.patterns.join("\n"),
options.constraints ?? "",
options.maxFileSize ?? 0,
options.maxMatchesPerFile ?? 0,
options.smartCase ?? true,
options.cursor?._offset ?? 0,
options.pageSize ?? 0,
options.timeBudgetMs ?? 0,
options.beforeContext ?? 0,
options.afterContext ?? 0,
options.classifyDefinitions ?? false,
);
}
/**
* Trigger a rescan of the indexed directory.
*
* This is useful after major file system changes that the
* background watcher might have missed.
*/
scanFiles(): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiScanFiles(guard.value);
}
/**
* Check if a scan is currently in progress.
*/
isScanning(): boolean {
if (this.handle === null) return false;
return ffiIsScanning(this.handle);
}
/**
* Get the base path of the file picker (the root directory being indexed).
*/
getBasePath(): Result<string | null> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetBasePath(guard.value);
}
/**
* Get the current scan progress.
*/
getScanProgress(): Result<ScanProgress> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetScanProgress(guard.value) as Result<ScanProgress>;
}
/**
* Wait for the initial file scan to complete.
* Non-blocking: polls `isScanning` and yields to the event loop between checks.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*
* @example
* ```typescript
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (finder.ok) {
* const completed = await finder.value.waitForScan(10000);
* if (!completed.ok || !completed.value) {
* console.warn("Scan did not complete in time");
* }
* }
* ```
*/
async waitForScan(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const deadline = Date.now() + timeoutMs;
while (this.isScanning()) {
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return { ok: true, value: true };
}
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer {@link waitForScan}
* unless you specifically need synchronous blocking behaviour.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*/
waitForScanBlocking(timeoutMs: number = 5000): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiWaitForScan(guard.value, timeoutMs);
}
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if the index became ready, false if timed out
*/
async waitForIndexReady(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const deadline = Date.now() + timeoutMs;
while (true) {
const progress = this.getScanProgress();
if (!progress.ok) return progress;
if (!progress.value.isScanning && progress.value.isWarmupComplete) {
return { ok: true, value: true };
}
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/**
* Change the indexed directory to a new path.
*
* This stops the current file watcher and starts indexing the new directory.
*
* @param newPath - New directory path to index
*/
reindex(newPath: string): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRestartIndex(guard.value, newPath);
}
/**
* Refresh the git status cache.
*
* @returns Number of files with updated git status
*/
refreshGitStatus(): Result<number> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRefreshGitStatus(guard.value);
}
/**
* Track query completion for smart suggestions.
*
* Call this when a user selects a file from search results.
* This helps improve future search rankings for similar queries.
*
* @param query - The search query that was used
* @param selectedFilePath - The file path that was selected
*/
trackQuery(query: string, selectedFilePath: string): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiTrackQuery(guard.value, query, selectedFilePath);
}
/**
* Get a historical query by offset.
*
* @param offset - Offset from most recent (0 = most recent)
* @returns The historical query string, or null if not found
*/
getHistoricalQuery(offset: number): Result<string | null> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetHistoricalQuery(guard.value, offset);
}
/**
* Get health check information.
*
* Useful for debugging and verifying the file finder is working correctly.
*
* @param testPath - Optional path to test git repository detection
*/
healthCheck(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(this.handle, testPath || "") as Result<HealthCheck>;
}
/**
* Check if the native library is available.
*/
static isAvailable(): boolean {
return isAvailable();
}
/**
* Ensure the native library is loaded.
*
* Loads the native library from the platform-specific npm package
* or a local dev build. Throws if the binary is not found.
*/
static ensureLoaded(): void {
ensureLoaded();
}
/**
* Get a health check without requiring an instance.
*
* Returns limited info (version + git only, no picker/frecency/query data).
*
* @param testPath - Optional path to test git repository detection
*/
static healthCheckStatic(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* fff - Fast File Finder
*
* High-performance fuzzy file finder for Node.js, powered by Rust.
* Perfect for LLM agent tools that need to search through codebases.
*
* Each `FileFinder` instance is backed by an independent native file picker.
* Create as many as you need and destroy them when done.
*
* Uses ffi-rs to load the same native libfff_c binary used by @ff-labs/fff-bun.
*
* @example
* ```typescript
* import { FileFinder } from "@ff-labs/fff-node";
*
* // Create a file finder instance
* const result = FileFinder.create({ basePath: "/path/to/project" });
* if (!result.ok) {
* console.error(result.error);
* process.exit(1);
* }
* const finder = result.value;
*
* // Wait for initial scan
* await finder.waitForScan(5000);
*
* // Search for files
* const search = finder.fileSearch("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
* }
* }
*
* // Cleanup when done
* finder.destroy();
* ```
*
* @packageDocumentation
*/
export {
binaryExists,
findBinary,
} from "./binary.js";
export { closeLibrary } from "./ffi.js";
export type {
DbHealth,
DirItem,
DirSearchOptions,
DirSearchResult,
FileFinderApi,
FileItem,
GrepCursor,
GrepMatch,
GrepMode,
GrepOptions,
GrepResult,
HealthCheck,
InitOptions,
Location,
MixedItem,
MixedSearchResult,
MultiGrepOptions,
Result,
ScanProgress,
Score,
SearchOptions,
SearchResult,
} from "./fff-api.js";
// Result helpers
export { err, ok } from "./fff-api.js";
export { FileFinder } from "./finder.js";
export {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "./platform.js";
+127
View File
@@ -0,0 +1,127 @@
/**
* Platform detection utilities for downloading the correct binary
*/
import { execSync } from "node:child_process";
/**
* Get the platform triple (e.g., "x86_64-unknown-linux-gnu")
*/
export function getTriple(): string {
const platform = process.platform;
const arch = process.arch;
let osName: string;
if (platform === "darwin") {
osName = "apple-darwin";
} else if (platform === "linux") {
osName = detectLinuxLibc();
} else if (platform === "win32") {
osName = "pc-windows-msvc";
} else {
throw new Error(`Unsupported platform: ${platform}`);
}
const archName = normalizeArch(arch);
return `${archName}-${osName}`;
}
/**
* Detect whether we're on musl or glibc Linux
*/
function detectLinuxLibc(): string {
let output = "";
try {
output = execSync("ldd --version 2>&1", {
encoding: "utf-8",
timeout: 5000,
});
} catch (e: unknown) {
const err = e as { stdout?: string | Buffer; stderr?: string | Buffer };
output = String(err?.stdout ?? "") + String(err?.stderr ?? "");
}
// ldd on musl can produce stdout with musl either with exit code 1 or 0
if (output.toLowerCase().includes("musl")) {
return "unknown-linux-musl";
}
return "unknown-linux-gnu";
}
/**
* Normalize architecture name to Rust target format
*/
function normalizeArch(arch: string): string {
switch (arch) {
case "x64":
case "amd64":
return "x86_64";
case "arm64":
return "aarch64";
case "arm":
return "arm";
default:
throw new Error(`Unsupported architecture: ${arch}`);
}
}
/**
* Get the library file extension for the current platform
*/
export function getLibExtension(): "dylib" | "so" | "dll" {
switch (process.platform) {
case "darwin":
return "dylib";
case "win32":
return "dll";
default:
return "so";
}
}
/**
* Get the library filename prefix (empty on Windows)
*/
export function getLibPrefix(): string {
return process.platform === "win32" ? "" : "lib";
}
/**
* Get the full library filename for the current platform
*/
export function getLibFilename(): string {
const prefix = getLibPrefix();
const ext = getLibExtension();
return `${prefix}fff_c.${ext}`;
}
/**
* Map from Rust target triple to npm platform package name.
* The @ff-labs/fff-bin-* packages contain the pre-built libfff_c
* shared library and are runtime-agnostic (used by both Bun and Node).
*/
const TRIPLE_TO_NPM_PACKAGE: Record<string, string> = {
"aarch64-apple-darwin": "@ff-labs/fff-bin-darwin-arm64",
"x86_64-apple-darwin": "@ff-labs/fff-bin-darwin-x64",
"x86_64-unknown-linux-gnu": "@ff-labs/fff-bin-linux-x64-gnu",
"aarch64-unknown-linux-gnu": "@ff-labs/fff-bin-linux-arm64-gnu",
"x86_64-unknown-linux-musl": "@ff-labs/fff-bin-linux-x64-musl",
"aarch64-unknown-linux-musl": "@ff-labs/fff-bin-linux-arm64-musl",
"x86_64-pc-windows-msvc": "@ff-labs/fff-bin-win32-x64",
"aarch64-pc-windows-msvc": "@ff-labs/fff-bin-win32-arm64",
};
/**
* Get the npm package name for the current platform's native binary.
*
* @returns Package name like "@ff-labs/fff-bin-darwin-arm64"
* @throws If the current platform is not supported
*/
export function getNpmPackageName(): string {
const triple = getTriple();
const packageName = TRIPLE_TO_NPM_PACKAGE[triple];
if (!packageName) {
throw new Error(`No npm package available for platform: ${triple}`);
}
return packageName;
}
View File
+21
View File
@@ -0,0 +1,21 @@
import { FileFinder } from "@ff-labs/fff-node";
const finder = FileFinder.create({ basePath: "~/dev/linux-root" });
if (!finder.ok) {
throw new Error(`Failed to create FileFinder: ${finder.error}`);
}
const scan = await finder.value.waitForScan(5_000);
if (!scan.ok) {
throw new Error(`Failed to wait for scan: ${scan.error}`);
}
// 5ms on m1 mac at the linux kernel repo root
const result = finder.value.grep("hardwre", { mode: "plain" });
for (const match of result.value.items) {
console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
}
console.log(`\n${result.value.totalMatched} matches across ${result.value.totalFilesSearched} files`);
finder.value.destroy();
+353
View File
@@ -0,0 +1,353 @@
import { after, before, describe, it } from "node:test";
import { strict as assert } from "node:assert";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { FileFinder } from "../dist/src/index.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, "..", "..", "..");
const normalizePath = (p) => p.replace(/\\/g, "/");
/** @type {import("../dist/src/finder.js").FileFinder | null} */
let finder = null;
describe("fff-node", { concurrency: 1 }, () => {
before(async () => {
const result = FileFinder.create({ basePath: REPO_ROOT });
assert.ok(result.ok, `create failed: ${!result.ok ? result.error : ""}`);
finder = result.value;
const wait = await finder.waitForScan(5_000);
if (!wait.ok) {
assert.ok(wait.ok, `waitForScan failed: ${wait.error}`);
}
assert.equal(wait.value, true, "scan should finish within 5s");
});
after(() => {
if (finder && !finder.isDestroyed) {
finder.destroy();
}
});
it("isAvailable returns true when the native library is loadable", () => {
assert.equal(FileFinder.isAvailable(), true);
});
it("getScanProgress reports >100 indexed files", () => {
const r = finder.getScanProgress();
assert.ok(r.ok, "getScanProgress failed");
assert.equal(typeof r.value.scannedFilesCount, "number");
assert.ok(
r.value.scannedFilesCount > 100,
`expected >100, got ${r.value.scannedFilesCount}`,
);
assert.equal(r.value.isScanning, false);
});
describe("search", { concurrency: 1 }, () => {
it("finds the root Cargo.toml", () => {
const r = finder.fileSearch("Cargo.toml", { pageSize: 10 });
assert.ok(r.ok, `search failed: ${!r.ok ? r.error : ""}`);
const paths = r.value.items.map((i) => i.relativePath);
assert.ok(
paths.includes("Cargo.toml"),
`expected Cargo.toml in results, got: ${paths}`,
);
});
it("finds crates/fff-c/src/lib.rs", () => {
const r = finder.fileSearch("lib.rs", { pageSize: 20 });
assert.ok(r.ok);
const paths = r.value.items.map((i) => i.relativePath);
assert.ok(
paths.some((p) => p.includes("fff-c") && p.endsWith("lib.rs")),
`expected fff-c/src/lib.rs, got: ${paths}`,
);
});
it("respects pageSize", () => {
const r = finder.fileSearch("lua", { pageSize: 3 });
assert.ok(r.ok);
assert.ok(
r.value.items.length <= 3,
`expected <=3, got ${r.value.items.length}`,
);
});
it("items contain all required fields", () => {
const r = finder.fileSearch("Cargo.toml", { pageSize: 1 });
assert.ok(r.ok);
const item = r.value.items[0];
for (const f of ["relativePath", "fileName", "gitStatus"]) {
assert.equal(typeof item[f], "string", `${f} should be a string`);
}
for (const f of ["size", "modified"]) {
assert.equal(typeof item[f], "number", `${f} should be a number`);
}
});
it("scores contain all required fields", () => {
const r = finder.fileSearch("Cargo.toml", { pageSize: 1 });
assert.ok(r.ok);
assert.ok(r.value.scores.length > 0);
const s = r.value.scores[0];
for (const f of ["total", "baseScore"]) {
assert.equal(typeof s[f], "number", `${f} should be a number`);
}
assert.equal(typeof s.matchType, "string");
});
it("totalMatched <= totalFiles", () => {
const r = finder.fileSearch("rs", { pageSize: 1 });
assert.ok(r.ok);
assert.ok(r.value.totalMatched > 0);
assert.ok(r.value.totalFiles > 100);
assert.ok(r.value.totalFiles >= r.value.totalMatched);
});
it("empty query returns files", () => {
const r = finder.fileSearch("", { pageSize: 5 });
assert.ok(r.ok, `empty query failed: ${!r.ok ? r.error : ""}`);
assert.equal(typeof r.value.totalFiles, "number");
});
});
describe("glob", { concurrency: 1 }, () => {
it("filters by extension via raw glob pattern", () => {
const r = finder.glob("**/*.rs", { pageSize: 50 });
assert.ok(r.ok, `glob failed: ${!r.ok ? r.error : ""}`);
assert.ok(r.value.items.length > 0, "expected at least one .rs file");
for (const item of r.value.items) {
assert.ok(
item.relativePath.endsWith(".rs"),
`unexpected file: ${item.relativePath}`,
);
}
});
it("returns empty result for non-matching pattern", () => {
const r = finder.glob("**/this-extension-does-not-exist-anywhere.zzz");
assert.ok(r.ok);
assert.equal(r.value.items.length, 0);
});
it("rejects empty pattern", () => {
const r = finder.glob("");
assert.equal(r.ok, false);
});
it("respects pageSize", () => {
const r = finder.glob("**/*.rs", { pageSize: 3 });
assert.ok(r.ok);
assert.ok(r.value.items.length <= 3);
});
});
describe("grep", { concurrency: 1 }, () => {
it("finds FffResult in Rust sources", () => {
// Constrain to .rs files so the assertion doesn't depend on result ordering
// or content-indexing timing for other file types.
const rustResults = finder.grep("*.rs FffResult", { mode: "plain" });
assert.ok(
rustResults.ok,
`grep failed: ${!rustResults.ok ? rustResults.error : ""}`,
);
assert.ok(
rustResults.value.items.length > 0,
"expected at least one .rs match",
);
assert.ok(
rustResults.value.items.some((m) => m.relativePath.endsWith(".rs")),
);
const cResults = finder.grep("!**/*.{js,ts,rs} FffResult", {
mode: "plain",
});
assert.ok(
cResults.ok,
`grep failed: ${!cResults.ok ? cResults.error : ""}`,
);
assert.ok(
cResults.value.items.length > 0,
"expected at least one non-js/ts/rs match",
);
assert.ok(
cResults.value.items.some((m) => m.relativePath.endsWith(".h")),
);
});
it("match items contain all required fields", () => {
const r = finder.grep("FffResult", { mode: "plain" });
assert.ok(r.ok);
const m = r.value.items[0];
assert.equal(typeof m.relativePath, "string");
assert.equal(typeof m.lineNumber, "number");
assert.ok(m.lineNumber > 0, "lineNumber is 1-based");
assert.equal(typeof m.lineContent, "string");
assert.ok(m.lineContent.includes("FffResult"));
assert.equal(typeof m.col, "number");
assert.equal(typeof m.byteOffset, "number");
assert.ok(Array.isArray(m.matchRanges));
console.log(m.matchRanges);
});
it("pagination returns a second page", () => {
const r = finder.grep("fn", { mode: "plain" });
assert.ok(r.ok);
if (r.value.nextCursor) {
const r2 = finder.grep("fn", { cursor: r.value.nextCursor });
assert.ok(r2.ok, `page 2 failed: ${!r2.ok ? r2.error : ""}`);
assert.equal(typeof r2.value.totalMatched, "number");
}
});
it("respects pageSize", () => {
// Cap to one match per file so pageSize bounds the total deterministically.
const unbounded = finder.grep("fn", {
mode: "plain",
maxMatchesPerFile: 1,
});
assert.ok(
unbounded.ok,
`grep failed: ${!unbounded.ok ? unbounded.error : ""}`,
);
assert.ok(unbounded.value.items.length > 2);
const limited = finder.grep("fn", {
mode: "plain",
maxMatchesPerFile: 1,
pageSize: 2,
});
assert.ok(limited.ok, `grep failed: ${!limited.ok ? limited.error : ""}`);
assert.ok(
limited.value.items.length <= 2,
`expected <=2 items, got ${limited.value.items.length}`,
);
assert.ok(
limited.value.items.length < unbounded.value.items.length,
"limited page should yield fewer matches than the default",
);
assert.ok(limited.value.nextCursor !== null, "nextCursor should be set");
});
it("regex mode matches pub fn declarations", () => {
const r = finder.grep("pub fn \\w+", { mode: "regex" });
assert.ok(r.ok, `regex grep failed: ${!r.ok ? r.error : ""}`);
assert.ok(r.value.items.length > 0);
});
it("decodes before/after context lines", () => {
const r = finder.grep(
"LOLLOWOIEJIWOIUOIWUIWUIOUWE", // the random text visible here
{
mode: "plain",
beforeContext: 1,
afterContext: 1,
maxMatchesPerFile: 5,
},
);
assert.ok(r.ok, `grep with context failed: ${!r.ok ? r.error : ""}`);
const match = r.value.items.find(
(m) =>
normalizePath(m.relativePath) === "packages/fff-node/test/e2e.mjs",
);
assert.ok(
match,
`expected a single match in the codebase, got: ${r.value.items
.map((m) => normalizePath(m.relativePath))
.join(", ")}`,
);
assert.deepEqual(match.contextBefore, [" const r = finder.grep("]);
assert.deepEqual(match.contextAfter, [" {"]);
});
});
describe("multiGrep", { concurrency: 1 }, () => {
it("finds lines matching any of the C FFI function names", () => {
const r = finder.multiGrep({
patterns: ["fff_create_instance", "fff_destroy", "fff_search"],
});
assert.ok(r.ok, `multiGrep failed: ${!r.ok ? r.error : ""}`);
assert.ok(r.value.items.length > 0);
});
it("rejects empty patterns array", () => {
const r = finder.multiGrep({ patterns: [] });
assert.ok(!r.ok);
});
});
it("refreshGitStatus returns a positive count", () => {
const r = finder.refreshGitStatus();
assert.ok(r.ok, `refreshGitStatus failed: ${!r.ok ? r.error : ""}`);
assert.equal(typeof r.value, "number");
assert.ok(r.value > 0);
});
it("isScanning returns a boolean", () => {
assert.equal(typeof finder.isScanning(), "boolean");
});
describe("healthCheck", { concurrency: 1 }, () => {
it("reports initialized state with instance", () => {
const r = finder.healthCheck(REPO_ROOT);
assert.ok(r.ok, `healthCheck failed: ${!r.ok ? r.error : ""}`);
assert.equal(typeof r.value.version, "string");
assert.ok(r.value.version.length > 0);
assert.equal(r.value.git.available, true);
assert.equal(r.value.git.repositoryFound, true);
assert.equal(r.value.filePicker.initialized, true);
assert.ok(r.value.filePicker.indexedFiles > 100);
});
it("static check works without instance", () => {
const r = FileFinder.healthCheckStatic(REPO_ROOT);
assert.ok(r.ok, `static healthCheck failed: ${!r.ok ? r.error : ""}`);
assert.equal(typeof r.value.version, "string");
assert.equal(r.value.filePicker.initialized, false);
assert.equal(r.value.git.repositoryFound, true);
});
});
it("create rejects a non-existent path", () => {
const r = FileFinder.create({ basePath: "/nonexistent/fff-test-path" });
assert.ok(!r.ok);
assert.ok(
r.error.toLowerCase().includes("invalid path") ||
r.error.toLowerCase().includes("not exist"),
`unexpected error: ${r.error}`,
);
});
// This must be the last describe block since it destroys the shared instance.
describe("after destroy", { concurrency: 1 }, () => {
before(() => {
finder.destroy();
});
it("isDestroyed is true", () => {
assert.equal(finder.isDestroyed, true);
});
it("search returns an error", () => {
const r = finder.fileSearch("test");
assert.ok(!r.ok);
assert.ok(r.error.includes("destroyed"));
});
it("grep returns an error", () => {
const r = finder.grep("test");
assert.ok(!r.ok);
assert.ok(r.error.includes("destroyed"));
});
it("double destroy is a safe no-op", () => {
finder.destroy();
assert.equal(finder.isDestroyed, true);
});
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* Stress reproducer for issue #515.
*
* Repeatedly creates a FileFinder, waits for scan, runs mixed file / dir /
* grep operations with periodic scanFiles() and refreshGitStatus() calls,
* destroys it, then repeats across two repos.
*
* Usage:
* node test/stress-515.mjs [iterations] [repoA] [repoB]
*/
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { FileFinder } from "../dist/src/index.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, "..", "..", "..");
const args = process.argv.slice(2);
const ITERS = Number(args[0] || process.env.FFF_STRESS_ITERS || 50);
const REPO_A = resolve(args[1] || process.env.FFF_STRESS_REPO_A || REPO_ROOT);
const REPO_B_CANDIDATE = args[2] || process.env.FFF_STRESS_REPO_B || resolve(REPO_ROOT, "big-repo");
const REPO_B = existsSync(REPO_B_CANDIDATE) ? resolve(REPO_B_CANDIDATE) : REPO_A;
const REPOS = REPO_A === REPO_B ? [REPO_A] : [REPO_A, REPO_B];
const SEARCH_QUERIES = [
"main",
"lib",
"fn",
"TODO",
"config",
"README",
"Cargo",
"test",
"src/",
"init",
"pub",
"use",
];
const GREP_QUERIES = [
"fn ",
"pub fn",
"TODO",
"FIXME",
"use std",
"impl ",
"struct ",
"let mut",
"return",
"match ",
];
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
async function runIteration(iter) {
const base = REPOS[iter % REPOS.length];
process.stdout.write(`[iter ${iter}] base=${base} `);
const created = FileFinder.create({
basePath: base,
logFilePath: `/tmp/fff-515-${iter}.log`,
logLevel: "debug",
});
if (!created.ok) {
console.error(`create failed: ${created.error}`);
return false;
}
const finder = created.value;
const wait = await finder.waitForScan(60_000);
if (!wait.ok || !wait.value) {
console.error(`waitForScan failed: ${JSON.stringify(wait)}`);
finder.destroy();
return false;
}
process.stdout.write("scan-done ");
const opCount = 30 + Math.floor(Math.random() * 30);
for (let i = 0; i < opCount; i++) {
const r = Math.random();
if (r < 0.35) {
finder.fileSearch(pick(SEARCH_QUERIES), { pageSize: 20 });
} else if (r < 0.55) {
finder.directorySearch(pick(SEARCH_QUERIES), { pageSize: 20 });
} else if (r < 0.85) {
finder.grep(pick(GREP_QUERIES), { mode: "plain", pageSize: 20 });
} else if (r < 0.93) {
finder.scanFiles();
} else {
finder.refreshGitStatus();
}
}
process.stdout.write("ops-done ");
finder.destroy();
process.stdout.write("destroyed\n");
return true;
}
(async () => {
console.log(`Running ${ITERS} iterations across:`);
console.log(` A: ${REPO_A}`);
console.log(` B: ${REPO_B}`);
for (let i = 0; i < ITERS; i++) {
const ok = await runIteration(i);
if (!ok) {
console.error(`Aborting at iteration ${i}`);
process.exit(1);
}
}
console.log("Completed without crash.");
})();
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": ".",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "bin"]
}
+1
View File
@@ -0,0 +1 @@
3.10
+75
View File
@@ -0,0 +1,75 @@
# fff-search
Python bindings for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim), built with [PyO3](https://pyo3.rs/) and [Maturin](https://www.maturin.rs/). Install with `pip install fff-search`; import as `fff`.
## Requirements
- Python >= 3.10
- Rust toolchain (to build the native extension)
- [uv](https://docs.astral.sh/uv/) (recommended)
## Development setup
```bash
cd packages/fff-python
uv sync --all-extras
uv run maturin develop --release
```
## Running tests
```bash
cd packages/fff-python
uv run pytest -v
```
## Standalone example
```bash
cd packages/fff-python
uv run python examples/basic.py .
```
## Basic usage
```python
from fff import FileFinder
with FileFinder("/path/to/project", watch=False) as finder:
finder.wait_for_scan_blocking(timeout_ms=5000)
print(f"Indexed under {finder.base_path}")
result = finder.search("main")
if result:
print(f"Showing {len(result)} of {result.total_matched} matches")
for item, score in zip(result.items, result.scores):
print(f"{item.relative_path}: {score.total}")
```
### Async usage
`wait_for_scan` is a coroutine that polls the scan status and yields to the
event loop, so it never blocks other tasks. Use `wait_for_scan_blocking` from
synchronous code.
```python
import asyncio
from fff import FileFinder
async def main():
with FileFinder("/path/to/project", watch=False) as finder:
await finder.wait_for_scan(timeout_ms=5000)
result = finder.search("main")
print(result)
asyncio.run(main())
```
## Building wheels
```bash
cd packages/fff-python
uv run maturin build --release
```
The produced wheel is `abi3` compatible with Python 3.10+.
+43
View File
@@ -0,0 +1,43 @@
"""Standalone example of using fff Python bindings."""
from __future__ import annotations
import sys
import time
from fff import FileFinder
def main() -> int:
base_path = sys.argv[1] if len(sys.argv) > 1 else "."
print(f"Indexing {base_path}...")
start = time.time()
with FileFinder(base_path, watch=False) as finder:
print(f"Created in {time.time() - start:.2f}s")
print("Waiting for scan...")
finder.wait_for_scan_blocking(timeout_ms=30000)
progress = finder.scan_progress
print(f"Indexed {progress.scanned_files_count} files")
print("\nFuzzy file search for 'main':")
result = finder.search("main", page_size=5)
for item, score in zip(result.items, result.scores):
print(f" {item.relative_path:<50} score={score.total}")
print("\nGlob search '*.py':")
result = finder.glob("*.py", page_size=5)
for item in result.items:
print(f" {item.relative_path}")
print("\nGrep for 'def ':")
result = finder.grep("def ", page_limit=5)
for match in result.items:
print(f" {match.relative_path}:{match.line_number}: {match.line_content.strip()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+33
View File
@@ -0,0 +1,33 @@
[project]
name = "fff-search"
version = "0.9.6"
description = "Python bindings for FFF (Fast File Finder)"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
dependencies = []
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"maturin>=1.0",
]
[project.urls]
Repository = "https://github.com/dmtrKovalenko/fff.nvim"
Issues = "https://github.com/dmtrKovalenko/fff.nvim/issues"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[tool.maturin]
manifest-path = "../../crates/fff-python/Cargo.toml"
module-name = "fff._fff_python"
python-source = "src"
exclude = ["src/**/__pycache__/*", "src/**/*.pyc"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
+70
View File
@@ -0,0 +1,70 @@
"""Python bindings for FFF (Fast File Finder)."""
from __future__ import annotations
import asyncio
from fff._fff_python import (
DirItem,
DirSearchResult,
FFFException,
FileItem,
GrepMatch,
GrepResult,
GrepCursor,
MatchRange,
MixedDirItem,
MixedFileItem,
MixedSearchResult,
ScanProgress,
Score,
SearchResult,
)
from fff._fff_python import FileFinder as _FileFinder
_SCAN_POLL_INTERVAL = 0.05
class FileFinder(_FileFinder):
"""File finder with an async, event-loop-friendly scan wait.
Inherits every method from the native finder; only adds the async
``wait_for_scan`` on top. Use ``wait_for_scan_blocking`` when a
synchronous wait is acceptable.
"""
async def wait_for_scan(self, timeout_ms: int = 5000) -> bool:
"""Wait for the initial scan without blocking the event loop.
Polls ``is_scanning`` and yields to the loop between checks.
Returns ``True`` if the scan completed, ``False`` on timeout.
"""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout_ms / 1000
while self.is_scanning():
if loop.time() >= deadline:
return False
await asyncio.sleep(_SCAN_POLL_INTERVAL)
return True
__version__ = "0.9.6"
__all__ = [
"FFFException",
"FileFinder",
"FileItem",
"DirItem",
"Score",
"SearchResult",
"DirSearchResult",
"MixedFileItem",
"MixedDirItem",
"MixedSearchResult",
"MatchRange",
"GrepMatch",
"GrepResult",
"GrepCursor",
"ScanProgress",
"__version__",
]
+256
View File
@@ -0,0 +1,256 @@
"""Type stubs for fff Python bindings."""
from __future__ import annotations
from collections.abc import Sequence
from os import PathLike
from typing import Any, Literal, TypeAlias
_PathInput: TypeAlias = str | PathLike[str]
_GrepMode: TypeAlias = Literal["plain", "regex", "fuzzy"]
__version__: str
class FFFException(Exception):
"""Base exception for fff errors."""
class Score:
total: int
base_score: int
filename_bonus: int
special_filename_bonus: int
frecency_boost: int
distance_penalty: int
current_file_penalty: int
combo_match_boost: int
path_alignment_bonus: int
exact_match: bool
match_type: str
def __repr__(self) -> str: ...
class FileItem:
relative_path: str
file_name: str
git_status: str
size: int
modified: int
access_frecency_score: int
modification_frecency_score: int
total_frecency_score: int
is_binary: bool
def __repr__(self) -> str: ...
class DirItem:
relative_path: str
dir_name: str
max_access_frecency: int
def __repr__(self) -> str: ...
class MixedFileItem:
relative_path: str
file_name: str
git_status: str
size: int
modified: int
access_frecency_score: int
modification_frecency_score: int
total_frecency_score: int
is_binary: bool
def __repr__(self) -> str: ...
class MixedDirItem:
relative_path: str
dir_name: str
max_access_frecency: int
def __repr__(self) -> str: ...
class MatchRange:
start: int
end: int
def __repr__(self) -> str: ...
class GrepMatch:
relative_path: str
file_name: str
git_status: str
line_content: str
match_ranges: list[MatchRange]
context_before: list[str]
context_after: list[str]
size: int
modified: int
total_frecency_score: int
access_frecency_score: int
modification_frecency_score: int
line_number: int
byte_offset: int
col: int
fuzzy_score: int | None
is_definition: bool
is_binary: bool
def __repr__(self) -> str: ...
class SearchResult:
items: list[FileItem]
scores: list[Score]
total_matched: int
total_files: int
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __repr__(self) -> str: ...
class DirSearchResult:
items: list[DirItem]
scores: list[Score]
total_matched: int
total_dirs: int
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __repr__(self) -> str: ...
class MixedSearchResult:
items: list[MixedFileItem | MixedDirItem]
scores: list[Score]
total_matched: int
total_files: int
total_dirs: int
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __repr__(self) -> str: ...
class GrepResult:
items: list[GrepMatch]
total_matched: int
total_files_searched: int
total_files: int
filtered_file_count: int
next_file_offset: int
regex_fallback_error: str | None
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
@property
def has_more(self) -> bool: ...
def next_cursor(self) -> GrepCursor | None: ...
def __repr__(self) -> str: ...
class ScanProgress:
scanned_files_count: int
is_scanning: bool
is_watcher_ready: bool
is_warmup_complete: bool
def __repr__(self) -> str: ...
class GrepCursor:
offset: int
def __init__(self, offset: int) -> None: ...
def __repr__(self) -> str: ...
class FileFinder:
def __init__(
self,
base_path: _PathInput,
*,
frecency_db_path: _PathInput | None = None,
history_db_path: _PathInput | None = None,
enable_mmap_cache: bool = True,
enable_content_indexing: bool = True,
watch: bool = True,
ai_mode: bool = False,
log_file_path: _PathInput | None = None,
log_level: str | None = None,
cache_budget_max_files: int = 0,
cache_budget_max_bytes: int = 0,
cache_budget_max_file_size: int = 0,
enable_fs_root_scanning: bool = False,
enable_home_dir_scanning: bool = False,
) -> None: ...
def __enter__(self) -> FileFinder: ...
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ...
def __repr__(self) -> str: ...
@property
def closed(self) -> bool: ...
@property
def base_path(self) -> str | None: ...
@property
def scan_progress(self) -> ScanProgress: ...
def close(self) -> None: ...
def search(
self,
query: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
combo_boost_score_multiplier: int = 0,
min_combo_count: int = 0,
) -> SearchResult: ...
def glob(
self,
pattern: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
) -> SearchResult: ...
def directory_search(
self,
query: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
) -> DirSearchResult: ...
def mixed_search(
self,
query: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
combo_boost_score_multiplier: int = 0,
min_combo_count: int = 0,
) -> MixedSearchResult: ...
def grep(
self,
query: str,
*,
mode: _GrepMode = "plain",
max_file_size: int = 0,
max_matches_per_file: int = 0,
smart_case: bool = True,
cursor: GrepCursor | None = None,
page_limit: int = 0,
time_budget_ms: int = 0,
before_context: int = 0,
after_context: int = 0,
classify_definitions: bool = False,
) -> GrepResult: ...
def multi_grep(
self,
patterns: Sequence[str],
*,
constraints: str | None = None,
mode: _GrepMode = "plain",
max_file_size: int = 0,
max_matches_per_file: int = 0,
smart_case: bool = True,
cursor: GrepCursor | None = None,
page_limit: int = 0,
time_budget_ms: int = 0,
before_context: int = 0,
after_context: int = 0,
classify_definitions: bool = False,
) -> GrepResult: ...
def scan_files(self) -> None: ...
def is_scanning(self) -> bool: ...
async def wait_for_scan(self, timeout_ms: int = 5000) -> bool: ...
def wait_for_scan_blocking(self, timeout_ms: int = 5000) -> bool: ...
def reindex(self, new_path: _PathInput) -> None: ...
def refresh_git_status(self) -> int: ...
def track_query(self, query: str, selected_file_path: _PathInput) -> bool: ...
def get_historical_query(self, offset: int) -> str | None: ...
def health_check(self, test_path: _PathInput | None = None) -> dict[str, Any]: ...
+391
View File
@@ -0,0 +1,391 @@
"""Tests for fff Python bindings."""
from __future__ import annotations
import importlib.metadata as metadata
import tempfile
from pathlib import Path
import pytest
import fff
from fff import FFFException, FileFinder, GrepCursor, MixedDirItem, MixedFileItem
def rel(path: str) -> str:
return path.replace("\\", "/")
@pytest.fixture
def sample_dir() -> str:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "src").mkdir()
(root / "docs").mkdir()
(root / "src" / "main.py").write_text(
"from utils import helper\n\n"
"def main():\n"
" value = helper()\n"
" return value\n"
)
(root / "src" / "utils.py").write_text(
"def helper():\n"
" return 'alpha'\n"
)
(root / "src" / "profile.ts").write_text(
"class Profile {}\n"
"function renderProfile() { return new Profile(); }\n"
)
(root / "docs" / "guide.txt").write_text(
"alpha line before\n"
"needle target\n"
"omega line after\n"
)
(root / "README.md").write_text("# Sample project\n")
yield str(root)
def test_imports_and_package_version() -> None:
assert fff.__version__ == metadata.version("fff-search")
assert GrepCursor(12).offset == 12
assert "GrepCursor" in fff.__all__
def test_pathlib_base_path(sample_dir: str) -> None:
with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.closed is False
assert finder.base_path is not None
assert finder.scan_progress.scanned_files_count >= 1
result = finder.search("main")
assert result.total_matched >= 1
async def test_wait_for_scan_async(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert await finder.wait_for_scan(timeout_ms=5000) is True
assert finder.is_scanning() is False
result = finder.search("main")
assert result.total_matched >= 1
async def test_wait_for_scan_async_does_not_block_loop(sample_dir: str) -> None:
import asyncio
ticks = 0
async def ticker() -> None:
nonlocal ticks
while True:
ticks += 1
await asyncio.sleep(0.01)
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
background = asyncio.ensure_future(ticker())
try:
assert await finder.wait_for_scan(timeout_ms=5000) is True
finally:
background.cancel()
# the loop kept running other tasks while we awaited the scan
assert ticks > 0
async def test_wait_for_scan_blocking_and_async_agree(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000) is True
# already finished, so the async wait resolves immediately to True
assert await finder.wait_for_scan(timeout_ms=5000) is True
def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None:
with pytest.raises(TypeError):
GrepCursor()
with pytest.raises(TypeError):
FileFinder(Path(sample_dir), None)
with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
with pytest.raises(TypeError):
finder.search("main", None)
with pytest.raises(TypeError):
finder.glob("*.py", None)
with pytest.raises(TypeError):
finder.directory_search("src", None)
with pytest.raises(TypeError):
finder.mixed_search("src", None)
with pytest.raises(TypeError):
finder.grep("needle", "plain")
with pytest.raises(TypeError):
finder.multi_grep(["needle"], None)
def test_close_and_context_manager(sample_dir: str) -> None:
finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False)
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.closed is False
assert finder.base_path is not None
finder.close()
assert finder.closed is True
with pytest.raises(FFFException, match="File picker not initialized"):
finder.search("main")
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as ctx_finder:
assert ctx_finder.wait_for_scan_blocking(timeout_ms=5000)
assert ctx_finder.closed is False
assert ctx_finder.closed is True
with pytest.raises(FFFException, match="File picker not initialized"):
ctx_finder.search("main")
fresh = FileFinder(sample_dir, watch=False, enable_content_indexing=False)
assert fresh.wait_for_scan_blocking(timeout_ms=5000)
fresh.close()
assert fresh.closed is True
with pytest.raises(FFFException, match="File picker not initialized"):
fresh.search("main")
def test_reprs(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert repr(finder).startswith("FileFinder(")
result = finder.search("main")
assert repr(result).startswith("SearchResult(")
assert repr(result.items[0]).startswith("FileItem(")
assert repr(result.scores[0]).startswith("Score(")
grep_result = finder.grep("needle")
assert repr(grep_result).startswith("GrepResult(")
assert repr(grep_result.items[0]).startswith("GrepMatch(")
assert repr(grep_result.items[0].match_ranges[0]).startswith("MatchRange(")
dir_result = finder.directory_search("src")
assert repr(dir_result).startswith("DirSearchResult(")
assert repr(dir_result.items[0]).startswith("DirItem(")
mixed = finder.mixed_search("src", page_size=10)
assert repr(mixed).startswith("MixedSearchResult(")
cursor = GrepCursor(42)
assert repr(cursor) == "GrepCursor(offset=42)"
progress = finder.scan_progress
assert repr(progress).startswith("ScanProgress(")
def test_file_search_scores_and_pagination(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.search("main", page_size=1)
assert result.total_matched >= 1
assert len(result) == len(result.items) == 1
assert bool(result) is True
assert any("main.py" in rel(item.relative_path) for item in result.items)
score = result.scores[0]
assert isinstance(score.total, int)
assert isinstance(score.exact_match, bool)
assert isinstance(score.match_type, str)
second_page = finder.search("", page_index=1, page_size=1)
assert len(second_page) == len(second_page.items) == 1
empty = finder.search("definitely_no_such_file_xyz")
assert len(empty) == 0
assert bool(empty) is False
def test_glob_variants(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
py_files = finder.glob("*.py")
assert {Path(rel(item.relative_path)).name for item in py_files.items} == {
"main.py",
"utils.py",
}
src_files = finder.glob("src/*.py")
assert src_files.total_matched == 2
md_files = finder.glob("*.md")
assert md_files.total_matched == 1
assert rel(md_files.items[0].relative_path) == "README.md"
def test_directory_and_mixed_search(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
dirs = finder.directory_search("src")
assert dirs.total_matched >= 1
assert len(dirs) == len(dirs.items)
assert bool(dirs) is True
assert any(rel(item.relative_path).startswith("src") for item in dirs.items)
mixed = finder.mixed_search("src", page_size=10)
assert mixed.total_matched >= 3
assert len(mixed) == len(mixed.items)
assert bool(mixed) is True
assert any(isinstance(item, MixedDirItem) for item in mixed.items)
assert any(isinstance(item, MixedFileItem) for item in mixed.items)
# max_access_frecency is the same field on both dir item types, so the
# values must agree for a shared directory regardless of which search
# produced them.
dir_frecency = {
rel(item.relative_path): item.max_access_frecency for item in dirs.items
}
for item in mixed.items:
if isinstance(item, MixedDirItem):
path = rel(item.relative_path)
if path in dir_frecency:
assert item.max_access_frecency == dir_frecency[path]
def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
plain = finder.grep("needle", before_context=1, after_context=1)
assert plain.total_matched == 1
assert len(plain) == len(plain.items) == 1
assert bool(plain) is True
match = plain.items[0]
assert rel(match.relative_path) == "docs/guide.txt"
assert match.line_content == "needle target"
assert match.context_before == ["alpha line before"]
assert match.context_after == ["omega line after"]
assert [(r.start, r.end) for r in match.match_ranges] == [(0, 6)]
regex = finder.grep(r"def \w+", mode="regex")
assert regex.total_matched == 2
assert {Path(rel(m.relative_path)).name for m in regex.items} == {
"main.py",
"utils.py",
}
fuzzy = finder.grep("df mn", mode="fuzzy")
assert fuzzy.total_matched >= 1
assert any(rel(m.relative_path) == "src/main.py" for m in fuzzy.items)
assert any(m.fuzzy_score is not None for m in fuzzy.items)
invalid = finder.grep("[", mode="regex")
assert invalid.regex_fallback_error is not None
def test_grep_invalid_mode_raises(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
with pytest.raises(FFFException, match="invalid grep mode"):
finder.grep("needle", mode="typo")
with pytest.raises(FFFException, match="invalid grep mode"):
finder.multi_grep(["needle"], mode="typo")
def test_grep_cursor_paginates_by_file(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
first = finder.grep("def", page_limit=1)
assert first.total_matched >= 1
assert first.next_file_offset > 0
assert first.has_more is True
assert first.next_cursor() is not None
assert first.next_cursor().offset == first.next_file_offset
second = finder.grep("def", cursor=GrepCursor(first.next_file_offset), page_limit=1)
assert second.total_matched >= 1
first_paths = {rel(m.relative_path) for m in first.items}
second_paths = {rel(m.relative_path) for m in second.items}
assert first_paths.isdisjoint(second_paths)
exhausted = finder.grep("nonexistent_xyz")
assert exhausted.has_more is False
assert exhausted.next_cursor() is None
assert len(exhausted) == 0
assert bool(exhausted) is False
def test_multi_grep_and_error_handling(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.multi_grep(("def main", "def helper"))
assert result.total_matched == 2
assert {Path(rel(m.relative_path)).name for m in result.items} == {
"main.py",
"utils.py",
}
with pytest.raises(FFFException, match="patterns must not be empty"):
finder.multi_grep([])
def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None:
history_db = tmp_path / "history"
selected_file = Path(sample_dir) / "src" / "main.py"
with FileFinder(
sample_dir,
history_db_path=history_db,
watch=False,
enable_content_indexing=False,
) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.track_query("main", selected_file)
assert finder.get_historical_query(0) == "main"
with FileFinder(
sample_dir,
history_db_path=history_db,
watch=False,
enable_content_indexing=False,
) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.get_historical_query(0) == "main"
def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None:
other = tmp_path / "other-project"
other.mkdir()
(other / "other.py").write_text("def other():\n return 42\n")
frecency_db = tmp_path / "frecency"
history_db = tmp_path / "history"
with FileFinder(
sample_dir,
frecency_db_path=frecency_db,
history_db_path=history_db,
watch=False,
enable_content_indexing=False,
) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
health = finder.health_check(Path(sample_dir))
assert health["file_picker"]["initialized"] is True
assert health["frecency"]["initialized"] is True
assert health["query_tracker"]["initialized"] is True
# With no explicit path, the check inspects the indexed base path
# rather than the process cwd.
default_health = finder.health_check()
assert default_health["file_picker"]["base_path"] is not None
assert "error" not in default_health.get("git", {}) or default_health["git"][
"error"
] != "could not determine current directory"
finder.reindex(str(other))
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.search("other")
assert result.total_matched == 1
assert rel(result.items[0].relative_path) == "other.py"
finder.reindex(Path(other))
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result2 = finder.search("other")
assert result2.total_matched == 1
+208
View File
@@ -0,0 +1,208 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "fff-search"
version = "0.9.6"
source = { editable = "." }
[package.optional-dependencies]
dev = [
{ name = "maturin" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "maturin", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
]
provides-extras = ["dev"]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "maturin"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/d0/b7c8b7778cc44df3efbc96eb23acaa995e06ea1a60eb9b02f29858fcbd08/maturin-1.14.0.tar.gz", hash = "sha256:f7f82a6aca4a6c402bf00b99200be199d4874d04b9b9e74e825726a3478bba7f", size = 367010, upload-time = "2026-06-12T00:13:30.811Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/51/49367dcd8f6ec139e69ef0c695c8ff5075223673382101812b4affa53216/maturin-1.14.0-py3-none-linux_armv6l.whl", hash = "sha256:019ea3ec7e71f4c9759a367d4d21022ed5a3a621a2ce123abf3fb114ab3711ca", size = 10204135, upload-time = "2026-06-12T00:13:34.308Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2a/487ce56c838d25e0ce64350e75ec4e3dc89544c0a6233221c229d6aa1a84/maturin-1.14.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6948a10f5f3470b791f79319be51debdd8bfd1778b36f2409f98e1314bc3859b", size = 19736800, upload-time = "2026-06-12T00:13:40.456Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a5/12f2efc18f419edce3282a93629cba16278bb502135dac95cd04ef7c2eae/maturin-1.14.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1506e86b1e273a98074a62e281b13f27ac96f8cdef85f7f98d3e3589a9387a23", size = 10201144, upload-time = "2026-06-12T00:13:26.842Z" },
{ url = "https://files.pythonhosted.org/packages/bf/95/3789e72273fd8bc80c33a11c787634b3251c4989d7a7203a92438836d4ff/maturin-1.14.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:df10ce4f7ba97fd3423f624f39b94c888ae3e5b470642a91918e1ccec81282fd", size = 10182394, upload-time = "2026-06-12T00:13:13.693Z" },
{ url = "https://files.pythonhosted.org/packages/40/79/15957eb4e055597f217e6310963a9c1371372e63c5b4a3e30803365addd2/maturin-1.14.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:75bcd4468a7fe597652cc2980c6bb16ce4bb8c411e3eb85dac2c4418cef0e95a", size = 10616603, upload-time = "2026-06-12T00:13:22.795Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4b/d1822f88cd5e855640f0e10ee00c39b9be614c1ef2f827e9792332d94b9f/maturin-1.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d123337e817f8dfe23755d6760139c01104137bb63e9e20c289c547e25ec857", size = 10075309, upload-time = "2026-06-12T00:13:38.274Z" },
{ url = "https://files.pythonhosted.org/packages/c0/82/c1b160d2163e8784489285e82a5c811fdcef3e0704e35b34c1cfe1828de3/maturin-1.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:107f84110d890090a01bb1ecd01761fdfae925c23c659ba492c9b83dd179eab4", size = 10024058, upload-time = "2026-06-12T00:13:16.49Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e8/88a9d1872997d4535af10ebe79f550e834880bf613cf8e50b50d2d938e3b/maturin-1.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:9a84277aa907961cd47ad26fef1539e79efa30611972eaf7499606e773e991b2", size = 13302073, upload-time = "2026-06-12T00:13:29.027Z" },
{ url = "https://files.pythonhosted.org/packages/4a/13/3f6d28bb7b744558b9bc78c995c1855d7e5ff21ad475f46d9de5c3dab039/maturin-1.14.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:095714b2a904927e3c868a1c5d078257ff0443c5049f7623777352966768306e", size = 10863616, upload-time = "2026-06-12T00:13:32.191Z" },
{ url = "https://files.pythonhosted.org/packages/24/06/39352d2b402efa3a7dd01d4ed197b301ea35eec10208ba2b8c649101f4df/maturin-1.14.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:20229d332f87166b930e4ca07cdbee8a1726f2eea87a337610aa25bba3ddf4b4", size = 10399943, upload-time = "2026-06-12T00:13:36.273Z" },
{ url = "https://files.pythonhosted.org/packages/58/77/641504541336240fef3836b2d15a785eaeb33c941fb118513c267dd70840/maturin-1.14.0-py3-none-win32.whl", hash = "sha256:4ba1e3c3f33609f461d587b7549104c81a15fd6d42ba63a73cea9376a1e9876e", size = 8905117, upload-time = "2026-06-12T00:13:18.38Z" },
{ url = "https://files.pythonhosted.org/packages/02/4a/ca247a0c43069b2f48cf783c5b13c3a9eb92c8f596dc7fbdb9f75fea4414/maturin-1.14.0-py3-none-win_amd64.whl", hash = "sha256:cb09a313f097adeb4dda0082277871a28d1bd26615dbadab42e6234b6df6fe69", size = 10309099, upload-time = "2026-06-12T00:13:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/8b/a4/f14a3f6086cc3caaa90d12e832e4aa41de771c310041959f0d35dd4efe17/maturin-1.14.0-py3-none-win_arm64.whl", hash = "sha256:8c1a8188195f5b6ce1aab99ae2d92e342900298f901456b43ca028947fd3b288", size = 9719100, upload-time = "2026-06-12T00:13:24.741Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+153
View File
@@ -0,0 +1,153 @@
# @ff-labs/pi-fff
A [pi](https://github.com/badlogic/pi-mono) extension that replaces the built-in `find` and `grep` tools with [FFF](https://github.com/dmtrKovalenko/fff.nvim) — a Rust-native, SIMD-accelerated file finder with built-in memory.
## What it does
| Built-in tool | pi-fff replacement | Improvement |
|---|---|---|
| `find` (spawns `fd`) | `fffind` (FFF `fileSearch`) | Fuzzy matching, frecency ranking, git-aware, pre-indexed |
| `grep` (spawns `rg`) | `ffgrep` (FFF `grep`) | SIMD-accelerated, frecency-ordered, mmap-cached, no subprocess |
| *(none)* | `fff-multi-grep` (FFF `multiGrep`) | OR-logic multi-pattern search via Aho-Corasick |
| `@` file autocomplete (fd-backed) | `@` file autocomplete (FFF-backed, default) | Fuzzy ranking from FFF index/frecency |
### Key advantages over built-in tools
- **No subprocess spawning** — FFF is a Rust native library called through the Node binding. No `fd`/`rg` process per call.
- **Pre-indexed** — files are indexed in the background at session start. Searches are instant.
- **Frecency ranking** — files you access often rank higher. Learns across sessions.
- **Query history** — remembers which files were selected for which queries. Combo boost.
- **Git-aware** — modified/staged/untracked files are boosted in results.
- **Smart case** — case-insensitive when query is all lowercase, case-sensitive otherwise.
- **Fuzzy file search** — `find` uses fuzzy matching, not glob-only. Typo-tolerant.
- **Cursor pagination** — grep results include a cursor for fetching the next page.
## Install
Requirements:
- pi
### Install as a pi package
**Via npm (recommended):**
```bash
pi install npm:@ff-labs/pi-fff
```
Project-local install:
```bash
pi install -l npm:@ff-labs/pi-fff
```
**Via git:**
```bash
pi install git:github.com/dmtrKovalenko/fff.nvim
```
Pin to a release:
```bash
pi install git:github.com/dmtrKovalenko/fff.nvim@v0.3.0
```
### Local development / manual install
```bash
git clone https://github.com/dmtrKovalenko/fff.nvim.git
cd fff.nvim/packages/pi-fff
npm install
```
Then add to your pi `settings.json`:
```json
{
"extensions": ["/path/to/fff.nvim/packages/pi-fff/src/index.ts"]
}
```
Or test directly:
```bash
pi -e /path/to/fff.nvim/packages/pi-fff/src/index.ts
```
This extension registers FFF-powered tools (`fffind`, `ffgrep`, `fff-multi-grep`) alongside pi's built-in tools.
## Tools
### `ffgrep`
Search file contents. Smart case, plain text by default, regex optional.
Parameters:
- `pattern` — search text or regex
- `path` — directory/file constraint (e.g. `src/`, `*.ts`)
- `ignoreCase` — force case-insensitive
- `literal` — treat as literal string (default: true)
- `context` — context lines around matches
- `limit` — max matches (default: 100)
- `cursor` — pagination cursor from previous result
### `fffind`
Fuzzy file name search. Frecency-ranked.
Parameters:
- `pattern` — fuzzy query (e.g. `main.ts`, `src/ config`)
- `path` — directory constraint
- `limit` — max results (default: 200)
### `fff-multi-grep`
OR-logic multi-pattern content search. SIMD-accelerated Aho-Corasick.
Parameters:
- `patterns` — array of literal patterns (OR logic)
- `constraints` — file constraints (e.g. `*.{ts,tsx} !test/`)
- `context` — context lines
- `limit` — max matches (default: 100)
- `cursor` — pagination cursor
## Commands
- `/fff-health` — show FFF status (indexed files, git info, frecency/history DB status)
- `/fff-rescan` — trigger a file rescan
- `/fff-mode <mode>` — switch mode (tool name change requires restart)
## Modes
- `tools-and-ui` (default): registers `fffind`, `ffgrep`, `fff-multi-grep` as additional tools + FFF-backed `@` autocomplete
- `tools-only`: additional tools only; keep pi's default `@` autocomplete
- `override`: replaces pi's built-in `find`, `grep` and adds `multi_grep` + FFF-backed `@` autocomplete
Mode precedence:
1. `--fff-mode <mode>` CLI flag
2. `PI_FFF_MODE=<mode>` environment variable
3. default (`tools-and-ui`)
## Flags
- `--fff-mode <mode>` — set mode (see above)
- `--fff-frecency-db <path>` — path to frecency database (also: `FFF_FRECENCY_DB` env)
- `--fff-history-db <path>` — path to query history database (also: `FFF_HISTORY_DB` env)
- `--fff-enable-root-scan` — allow indexing when launched from `/` (also: `FFF_ENABLE_ROOT_SCAN=1` env). FFF refuses to init at the filesystem root by default. Home directory scanning is always enabled for pi.
## Data
When database paths are provided, FFF stores:
- frecency database — file access frequency/recency
- history database — query-to-file selection history
No project files are uploaded anywhere by this extension. It runs locally and only uses the configured LLM through pi itself.
## Security
- No shell execution
- No network calls in the extension code
- No telemetry
- No credential handling beyond whatever pi and your configured model provider already do
- Search state is stored locally under `~/.pi/agent/fff/`
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@ff-labs/pi-fff",
"public": true,
"version": "0.6.0",
"description": "pi extension: FFF-powered fuzzy file and content search",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dmtrKovalenko/fff.git",
"directory": "packages/pi-fff"
},
"homepage": "https://github.com/dmtrKovalenko/fff/tree/main/packages/pi-fff",
"bugs": {
"url": "https://github.com/dmtrKovalenko/fff/issues"
},
"keywords": [
"pi",
"pi-package",
"pi-extension",
"fff",
"search",
"grep",
"fuzzy-search",
"ai-agent"
],
"pi": {
"extensions": [
"./src/index.ts"
]
},
"files": [
"src"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"test": "bun test test/",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ff-labs/fff-node": "*"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"@sinclair/typebox": "*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.0.0"
}
}
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
import path from "node:path";
export function normalizePathConstraint(
pathConstraint: string,
cwd = process.cwd(),
): string | null {
let trimmed = pathConstraint.trim();
if (!trimmed) return trimmed;
if (path.isAbsolute(trimmed)) {
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
if (relative === "") return null;
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
throw new Error(
`Path constraint must be relative to the workspace: ${pathConstraint}`,
);
}
trimmed = relative;
}
if (trimmed === "." || trimmed === "./") return null;
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
// FFF's glob matcher can treat a hidden directory root glob such as
// `.agents/**` as empty, while the tool contract says this means "inside
// this directory". Collapse simple trailing recursive directory globs to the
// directory-prefix constraint understood by the parser. Keep real file globs
// such as `src/**/*.ts` unchanged.
const recursiveDir = trimmed.match(/^(.*)\/\*\*(?:\/\*)?$/);
if (recursiveDir) {
const dir = recursiveDir[1];
if (dir && !/[*?[{]/.test(dir)) return `${dir}/`;
}
// Already signals path-constraint syntax to the parser.
if (trimmed.startsWith("/") || trimmed.endsWith("/")) return trimmed;
// Globs (`*.ts`, `src/**/*.cc`, `{src,lib}`) are handled by the parser.
if (/[*?[{]/.test(trimmed)) return trimmed;
// Filename with extension (`main.rs`, `config.json`) → FilePath constraint.
const lastSegment = trimmed.split("/").pop() ?? "";
if (/\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSegment)) return trimmed;
// Bare directory prefix → append `/` so the parser sees a PathSegment.
return `${trimmed}/`;
}
// Exclusions are emitted as `!<constraint>` tokens, which the Rust parser
// understands (crates/fff-query-parser/src/parser.rs). We normalize each one
// the same way as the include path so bare dirs become PathSegment excludes.
// Tolerate callers passing already-negated forms like `!src/` by stripping
// the leading `!` before normalizing so we never double-negate (`!!src/`).
export function normalizeExcludes(
exclude: string | string[] | undefined,
cwd = process.cwd(),
): string[] {
if (!exclude) return [];
const list = Array.isArray(exclude) ? exclude : [exclude];
const out: string[] = [];
for (const raw of list) {
const parts = raw
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
for (const p of parts) {
const stripped = p.startsWith("!") ? p.slice(1) : p;
const normalized = normalizePathConstraint(stripped, cwd);
if (normalized) out.push(`!${normalized}`);
}
}
return out;
}
export function buildQuery(
path: string | undefined,
pattern: string,
exclude?: string | string[],
cwd = process.cwd(),
): string {
const parts: string[] = [];
if (path) {
const pathConstraint = normalizePathConstraint(path, cwd);
if (pathConstraint) parts.push(pathConstraint);
}
parts.push(...normalizeExcludes(exclude, cwd));
parts.push(pattern);
return parts.join(" ");
}
+321
View File
@@ -0,0 +1,321 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
type MockFinder = {
isDestroyed: boolean;
waitForScan: ReturnType<typeof mock>;
mixedSearch: ReturnType<typeof mock>;
destroy: ReturnType<typeof mock>;
};
const createCalls: unknown[] = [];
let finders: MockFinder[] = [];
let mixedSearchImpl: ((query: string, options: unknown) => unknown) | undefined;
function createMockFinder(): MockFinder {
return {
isDestroyed: false,
waitForScan: mock(async () => undefined),
mixedSearch: mock((query: string, options: unknown) => {
if (mixedSearchImpl) return mixedSearchImpl(query, options);
return {
ok: true,
value: {
items: [],
scores: [],
totalMatched: 0,
totalFiles: 0,
totalDirs: 0,
},
};
}),
destroy: mock(function (this: MockFinder) {
this.isDestroyed = true;
}),
};
}
mock.module("@ff-labs/fff-node", () => ({
FileFinder: {
create: mock((options: unknown) => {
createCalls.push(options);
const finder = createMockFinder();
finders.push(finder);
return { ok: true, value: finder };
}),
},
}));
mock.module("@earendil-works/pi-tui", () => ({
Text: class Text {
text: string;
constructor(text: string) {
this.text = text;
}
setText(text: string) {
this.text = text;
}
},
}));
const schema = (type: string) => (options?: unknown) => ({ type, options });
mock.module("@sinclair/typebox", () => ({
Type: {
Array: (items: unknown, options?: unknown) => ({ type: "array", items, options }),
Boolean: schema("boolean"),
Number: schema("number"),
Object: (properties: unknown, options?: unknown) => ({
type: "object",
properties,
options,
}),
Optional: (value: unknown) => ({ ...value, optional: true }),
String: schema("string"),
Union: (items: unknown[], options?: unknown) => ({ type: "union", items, options }),
},
}));
const { default: fffExtension } = await import("../src/index");
type EventHandler = (...args: any[]) => unknown;
function createPi(mode?: string) {
const events = new Map<string, EventHandler>();
const commands = new Map<string, any>();
const pi = {
getFlag: mock((name: string) => (name === "fff-mode" ? mode : undefined)),
on: mock((event: string, handler: EventHandler) => {
events.set(event, handler);
}),
registerCommand: mock((name: string, command: any) => {
commands.set(name, command);
}),
registerFlag: mock(() => undefined),
registerTool: mock(() => undefined),
appendEntry: mock(() => undefined),
};
return { pi, events, commands };
}
function createContext() {
return {
cwd: "/tmp/workspace",
ui: {
addAutocompleteProvider: mock(() => undefined),
notify: mock(() => undefined),
setEditorComponent: mock(() => undefined),
},
};
}
async function start(mode?: string) {
const setup = createPi(mode);
const ctx = createContext();
fffExtension(setup.pi as any);
const sessionStart = setup.events.get("session_start");
expect(sessionStart).toBeDefined();
await sessionStart?.({ reason: "startup" }, ctx);
return { ...setup, ctx };
}
function currentProvider(
result = { items: [{ value: "base", label: "base" }], prefix: "ba" },
) {
return {
getSuggestions: mock(async () => result),
applyCompletion: mock(() => ({ lines: ["applied"], cursorLine: 0, cursorCol: 7 })),
shouldTriggerFileCompletion: mock(() => false),
};
}
function abortOptions() {
return { signal: new AbortController().signal };
}
beforeEach(() => {
createCalls.length = 0;
finders = [];
mixedSearchImpl = undefined;
delete process.env.PI_FFF_MODE;
});
describe("pi-fff autocomplete registration", () => {
test("session_start registers a provider without replacing the editor", async () => {
const { ctx } = await start();
expect(ctx.ui.addAutocompleteProvider).toHaveBeenCalledTimes(1);
expect(ctx.ui.setEditorComponent).not.toHaveBeenCalled();
expect(createCalls).toEqual([
{
basePath: "/tmp/workspace",
frecencyDbPath: undefined,
historyDbPath: undefined,
aiMode: true,
enableHomeDirScanning: true,
enableFsRootScanning: false,
},
]);
});
test("session_start survives hosts without addAutocompleteProvider", async () => {
const setup = createPi();
const ctx = {
cwd: "/tmp/workspace",
ui: {
notify: mock(() => undefined),
setEditorComponent: mock(() => undefined),
},
};
fffExtension(setup.pi as any);
const sessionStart = setup.events.get("session_start");
await sessionStart?.({ reason: "startup" }, ctx);
expect(ctx.ui.notify).not.toHaveBeenCalled();
expect(createCalls).toHaveLength(1);
});
test("delegates non-@ completions to the current provider", async () => {
const { ctx } = await start();
const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0];
const current = currentProvider();
const provider = factory(current);
const result = await provider.getSuggestions(["hello"], 0, 5, abortOptions());
expect(result).toEqual({ items: [{ value: "base", label: "base" }], prefix: "ba" });
expect(current.getSuggestions).toHaveBeenCalledTimes(1);
expect(finders[0].mixedSearch).not.toHaveBeenCalled();
});
test("returns FFF-backed @ mention suggestions", async () => {
mixedSearchImpl = (query, options) => {
expect(query).toBe("src");
expect(options).toEqual({ pageSize: 20 });
return {
ok: true,
value: {
items: [
{
type: "file",
item: {
relativePath: "src/index.ts",
fileName: "index.ts",
size: 1,
modified: 1,
accessFrecencyScore: 0,
modificationFrecencyScore: 0,
totalFrecencyScore: 0,
gitStatus: "clean",
},
},
{
type: "directory",
item: {
relativePath: "src/components/",
dirName: "components/",
maxAccessFrecency: 0,
},
},
],
scores: [],
totalMatched: 2,
totalFiles: 1,
totalDirs: 1,
},
};
};
const { ctx } = await start();
const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0];
const current = currentProvider();
const provider = factory(current);
const result = await provider.getSuggestions(["open @src"], 0, 9, abortOptions());
expect(result).toEqual({
prefix: "@src",
items: [
{
value: "@src/index.ts",
label: "index.ts",
description: "src/index.ts",
},
{
value: "@src/components/",
label: "components/",
description: "src/components/",
},
],
});
expect(current.getSuggestions).not.toHaveBeenCalled();
});
test("delegates when FFF lookup fails", async () => {
mixedSearchImpl = () => {
throw new Error("native lookup failed");
};
const { ctx } = await start();
const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0];
const current = currentProvider();
const provider = factory(current);
const result = await provider.getSuggestions(["@src"], 0, 4, abortOptions());
expect(result).toEqual({ items: [{ value: "base", label: "base" }], prefix: "ba" });
expect(current.getSuggestions).toHaveBeenCalledTimes(1);
});
test("tools-only mode bypasses FFF mentions and delegates", async () => {
const { ctx } = await start("tools-only");
const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0];
const current = currentProvider();
const provider = factory(current);
const result = await provider.getSuggestions(["@src"], 0, 4, abortOptions());
expect(result).toEqual({ items: [{ value: "base", label: "base" }], prefix: "ba" });
expect(current.getSuggestions).toHaveBeenCalledTimes(1);
expect(finders[0].mixedSearch).not.toHaveBeenCalled();
});
test("/fff-mode changes mention behavior without touching the editor", async () => {
const { commands, ctx } = await start();
const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0];
const current = currentProvider();
const provider = factory(current);
await commands.get("fff-mode").handler("tools-only", ctx);
await provider.getSuggestions(["@src"], 0, 4, abortOptions());
expect(current.getSuggestions).toHaveBeenCalledTimes(1);
expect(finders[0].mixedSearch).not.toHaveBeenCalled();
expect(ctx.ui.setEditorComponent).not.toHaveBeenCalled();
});
test("completion application and file-completion trigger delegate to current provider", async () => {
const { ctx } = await start();
const factory = ctx.ui.addAutocompleteProvider.mock.calls[0][0];
const current = currentProvider();
const provider = factory(current);
const applied = provider.applyCompletion(
["@src"],
0,
4,
{ value: "@src/index.ts", label: "index.ts" },
"@src",
);
const shouldTrigger = provider.shouldTriggerFileCompletion(["@src"], 0, 4);
expect(applied).toEqual({ lines: ["applied"], cursorLine: 0, cursorCol: 7 });
expect(shouldTrigger).toBe(false);
expect(current.applyCompletion).toHaveBeenCalledTimes(1);
expect(current.shouldTriggerFileCompletion).toHaveBeenCalledTimes(1);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import { buildQuery, normalizePathConstraint } from "../src/query";
const cwd = "/tmp/workspace";
describe("path constraint normalization", () => {
test("converts absolute in-workspace paths to repo-relative constraints", () => {
expect(normalizePathConstraint("/tmp/workspace/.agents/**", cwd)).toBe(".agents/");
expect(normalizePathConstraint("/tmp/workspace/.agents/plans/**", cwd)).toBe(
".agents/plans/",
);
});
test("rejects absolute paths outside the workspace", () => {
expect(() => normalizePathConstraint("/tmp/other/.agents/**", cwd)).toThrow(
"Path constraint must be relative to the workspace",
);
});
test("collapses only simple trailing recursive directory globs", () => {
expect(normalizePathConstraint(".agents/**", cwd)).toBe(".agents/");
expect(normalizePathConstraint("src/**/*", cwd)).toBe("src/");
expect(normalizePathConstraint("src/**/*.ts", cwd)).toBe("src/**/*.ts");
expect(normalizePathConstraint("{src,lib}/**", cwd)).toBe("{src,lib}/**");
});
test("builds find queries with normalized include and exclude constraints", () => {
expect(
buildQuery("/tmp/workspace/.agents/**", "*", "/tmp/workspace/test/**", cwd),
).toBe(".agents/ !test/ *");
});
test("treats path='.' as workspace root (no constraint)", () => {
expect(normalizePathConstraint(".", cwd)).toBeNull();
expect(normalizePathConstraint("./", cwd)).toBeNull();
expect(buildQuery(".", "needle", undefined, cwd)).toBe("needle");
});
test("treats absolute workspace root as no constraint", () => {
expect(normalizePathConstraint(cwd, cwd)).toBeNull();
expect(buildQuery(cwd, "needle", undefined, cwd)).toBe("needle");
});
test("bare directory path without trailing slash becomes PathSegment", () => {
expect(normalizePathConstraint("app", cwd)).toBe("app/");
expect(normalizePathConstraint("src/nested", cwd)).toBe("src/nested/");
expect(buildQuery("app", "needle", undefined, cwd)).toBe("app/ needle");
});
test("converts absolute in-workspace file path to repo-relative", () => {
expect(normalizePathConstraint("/tmp/workspace/src/main.rs", cwd)).toBe(
"src/main.rs",
);
expect(buildQuery("/tmp/workspace/src/main.rs", "needle", undefined, cwd)).toBe(
"src/main.rs needle",
);
});
test("converts absolute in-workspace directory (without trailing slash) to repo-relative", () => {
expect(normalizePathConstraint("/tmp/workspace/src", cwd)).toBe("src/");
expect(buildQuery("/tmp/workspace/src", "needle", undefined, cwd)).toBe(
"src/ needle",
);
});
test("converts absolute in-workspace glob path to repo-relative glob", () => {
expect(normalizePathConstraint("/tmp/workspace/src/**/*.ts", cwd)).toBe(
"src/**/*.ts",
);
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
}
+603
View File
@@ -0,0 +1,603 @@
/**
* The shared public API surface for the fff file finder, implemented identically
* by `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* This file is the single source of truth for every type, helper, and the
* `FileFinderApi` interface that crosses the package boundary. It is copied
* verbatim into each package's `src/fff-api.ts` by `make sync-api`.
*
* Anything that is not part of the public API (FFI struct layouts, binary
* loading, platform detection, etc.) stays as per-package internal
* implementation and must NOT live here.
*
* Keep this file self-contained: it must not import from any package-local
* module, since each package compiles its own copy.
*/
/**
* Result type for all operations - follows the Result pattern
*/
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
/**
* Helper to create a successful result
*/
export function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
/**
* Helper to create an error result
*/
export function err<T>(error: string): Result<T> {
return { ok: false, error };
}
/**
* Initialization options for the file finder
*/
export interface InitOptions {
/** Base directory to index (required) */
basePath: string;
/** Path to frecency database (optional, omit to skip frecency initialization) */
frecencyDbPath?: string;
/** Path to query history database (optional, omit to skip query tracker initialization) */
historyDbPath?: string;
/** @deprecated no-op */
useUnsafeNoLock?: boolean;
/**
* Disable mmap cache warmup after the initial scan. When mmap cache is
* enabled (the default), the first grep search is as fast as subsequent
* ones at the cost of a longer scan time and higher initial memory usage.
*/
disableMmapCache?: boolean;
/**
* Disable the content index built after the initial scan.
* Content indexing enables faster content-aware filtering during grep.
*/
disableContentIndexing?: boolean;
/**
* Disable the background file-system watcher. When the watcher is
* disabled, files are scanned once but not monitored for changes.
* (default: false)
*/
disableWatch?: boolean;
/** enables optimizations for AI agent assistants. Provide as true if running via mcp/agent */
aiMode?: boolean;
/**
* Path to the tracing log file. When set, the shared FFF tracing subscriber
* is installed on first init and file output is written here. Omit to leave
* logging uninitialized.
*/
logFilePath?: string;
/**
* Log level for the tracing subscriber: "trace", "debug", "info", "warn",
* or "error". Defaults to "info". Ignored when `logFilePath` is not set.
*/
logLevel?: "trace" | "debug" | "info" | "warn" | "error";
/**
* Override for the content cache file-count cap. When omitted, the picker
* auto-sizes the budget from the final scanned file count.
*/
cacheBudgetMaxFiles?: number;
/** Override for the content cache byte cap. See `cacheBudgetMaxFiles`. */
cacheBudgetMaxBytes?: number;
/** Override for the per-file byte cap in the content cache. */
cacheBudgetMaxFileSize?: number;
/**
* Allow indexing the filesystem root (`/`).
* Off by default, having fff instance at the large folder will generally require
* file watcher and indexing which will consume a lot of resources if performed uncontrolled
**/
enableFsRootScanning?: boolean;
/** Allow indexing the user's home directory. Same trade-off as `enableFsRootScanning`. */
enableHomeDirScanning?: boolean;
/** Follow symlinks for directories */
followSymlinks?: boolean;
}
/**
* Search options for fuzzy file search
*/
export interface SearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for deprioritization in results) */
currentFile?: string;
/** Combo boost score multiplier (default: 100) */
comboBoostMultiplier?: number;
/** Minimum combo count for boost (default: 3) */
minComboCount?: number;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Options for `glob`, the constraint-only search.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*/
export interface GlobOptions {
/** Maximum threads for parallel filtering (0 = auto). */
maxThreads?: number;
/** Current file path (for deprioritization in results). */
currentFile?: string;
/** Page index for pagination (default: 0). */
pageIndex?: number;
/** Page size for pagination (default: 100). */
pageSize?: number;
}
/**
* A file item in search results
*/
export interface FileItem {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Frecency score based on access patterns */
accessFrecencyScore: number;
/** Frecency score based on modification time */
modificationFrecencyScore: number;
/** Combined frecency score */
totalFrecencyScore: number;
/** Git status: 'clean', 'modified', 'untracked', 'staged_new', etc. */
gitStatus: string;
}
/**
* Score breakdown for a search result
*/
export interface Score {
/** Total combined score */
total: number;
/** Base fuzzy match score */
baseScore: number;
/** Bonus for filename match */
filenameBonus: number;
/** Bonus for special filenames (index.ts, main.rs, etc.) */
specialFilenameBonus: number;
/** Boost from frecency */
frecencyBoost: number;
/** Penalty for distance in path */
distancePenalty: number;
/** Penalty if this is the current file */
currentFilePenalty: number;
/** Boost from query history combo matching */
comboMatchBoost: number;
/** Whether this was an exact match */
exactMatch: boolean;
/** Type of match: 'fuzzy', 'exact', 'prefix', etc. */
matchType: string;
}
/**
* Location in file (from query like "file.ts:42")
*/
export type Location =
| { type: "line"; line: number }
| { type: "position"; line: number; col: number }
| {
type: "range";
start: { line: number; col: number };
end: { line: number; col: number };
};
/**
* Search result from fuzzy file search
*/
export interface SearchResult {
/** Matched file items */
items: FileItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of files that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Location parsed from query (e.g., "file.ts:42:10") */
location?: Location;
}
/**
* A directory item in search results
*/
export interface DirItem {
/** Path relative to the indexed directory (e.g., "src/components/") */
relativePath: string;
/** Last path segment (e.g., "components/" for "src/components/") */
dirName: string;
/** Maximum access frecency score among direct child files */
maxAccessFrecency: number;
}
/**
* Search options for directory search (subset of SearchOptions)
*/
export interface DirSearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for distance scoring) */
currentFile?: string;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Search result from fuzzy directory search
*/
export interface DirSearchResult {
/** Matched directory items */
items: DirItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of directories that matched */
totalMatched: number;
/** Total number of indexed directories */
totalDirs: number;
}
/**
* A single item in a mixed (files + directories) search result
*/
export type MixedItem =
| { type: "file"; item: FileItem }
| { type: "directory"; item: DirItem };
/**
* Search result from mixed (files + directories) fuzzy search.
* Items are interleaved by total score in descending order.
*/
export interface MixedSearchResult {
/** Matched items (files and directories interleaved by score) */
items: MixedItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of items (files + dirs) that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Total number of indexed directories */
totalDirs: number;
/** Location parsed from query */
location?: Location;
}
/**
* Scan progress information
*/
export interface ScanProgress {
/** Number of files scanned so far */
scannedFilesCount: number;
/** Whether a scan is currently in progress */
isScanning: boolean;
/** Whether the background file watcher is ready */
isWatcherReady: boolean;
/** Whether the warmup/bigram phase has completed */
isWarmupComplete: boolean;
}
/**
* Database health information
*/
export interface DbHealth {
/** Path to the database */
path: string;
/** Size of the database on disk in bytes */
diskSize: number;
}
/**
* Health check result
*/
export interface HealthCheck {
/** Library version */
version: string;
/** Git integration status */
git: {
/** Whether git2 library is available */
available: boolean;
/** Whether a git repository was found */
repositoryFound: boolean;
/** Git working directory path */
workdir?: string;
/** libgit2 version string */
libgit2Version: string;
/** Error message if git detection failed */
error?: string;
};
/** File picker status */
filePicker: {
/** Whether the file picker is initialized */
initialized: boolean;
/** Base path being indexed */
basePath?: string;
/** Whether a scan is in progress */
isScanning?: boolean;
/** Number of indexed files */
indexedFiles?: number;
/** Error message if there's an issue */
error?: string;
};
/** Frecency database status */
frecency: {
/** Whether frecency tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
/** Query tracker status */
queryTracker: {
/** Whether query tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
}
/**
* Grep search mode
*/
export type GrepMode = "plain" | "regex" | "fuzzy";
/**
* Opaque pagination cursor for grep results.
* Pass this to `GrepOptions.cursor` to fetch the next page.
* Do not construct or modify this — use the `nextCursor` from a previous `GrepResult`.
*/
export interface GrepCursor {
/** @internal */
readonly __brand: "GrepCursor";
/** @internal */
readonly _offset: number;
}
/**
* @internal Create a GrepCursor from a raw file offset.
*/
export function createGrepCursor(offset: number): GrepCursor {
return { __brand: "GrepCursor" as const, _offset: offset };
}
/**
* Options for live grep (content search)
*
* Files are searched sequentially in frecency order (most recently/frequently
* accessed first). The engine returns a `nextCursor` for fetching the next page.
*/
export interface GrepOptions {
/** Maximum file size to search in bytes. Files larger than this are skipped. (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 200) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when the query is all lowercase, case-sensitive otherwise (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/** Search mode (default: "plain") */
mode?: GrepMode;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. Let callers re-rank defs first
* without a TS-side regex port. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* A single grep match with file and line information
*/
export interface GrepMatch {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** Git status */
gitStatus: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Whether the file is binary */
isBinary: boolean;
/** Combined frecency score */
totalFrecencyScore: number;
/** Access-based frecency score */
accessFrecencyScore: number;
/** Modification-based frecency score */
modificationFrecencyScore: number;
/** 1-based line number of the match */
lineNumber: number;
/** 0-based byte column of first match start */
col: number;
/** Absolute byte offset of the matched line from file start */
byteOffset: number;
/** The matched line text (may be truncated) */
lineContent: string;
/** Byte offset pairs [start, end] within lineContent for highlighting */
matchRanges: [number, number][];
/** Fuzzy match score (only in fuzzy mode) */
fuzzyScore?: number;
/** Lines before the match (context). Empty array when context is 0. */
contextBefore?: string[];
/** Lines after the match (context). Empty array when context is 0. */
contextAfter?: string[];
/** Whether this line is a code definition (only populated when `classifyDefinitions: true`). */
isDefinition?: boolean;
}
/**
* Result from a grep search
*/
export interface GrepResult {
/** Matched items with file and line information. At most `max_matches_per_file`. */
items: GrepMatch[];
/** Total number of matches collected (always equal to items.length). */
totalMatched: number;
/** Number of files actually opened and searched in this call */
totalFilesSearched: number;
/** Total number of indexed files (before any filtering) */
totalFiles: number;
/** Number of files eligible for search after filtering out binary files, oversized files, and constraint mismatches */
filteredFileCount: number;
/**
* Cursor for the next page, or `null` if all eligible files have been searched.
* Pass this as `GrepOptions.cursor` to continue from where this call left off.
*/
nextCursor: GrepCursor | null;
/** When regex mode fails to compile the pattern, the engine falls back to literal matching and this field contains the compilation error */
regexFallbackError?: string;
}
/**
* Options for multi-pattern grep (Aho-Corasick multi-needle search)
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated Aho-Corasick multi-pattern matching.
*/
export interface MultiGrepOptions {
/** Patterns to search for (OR logic — matches lines containing any pattern) */
patterns: string[];
/** File constraints like "*.rs" or "/src/" */
constraints?: string;
/** Maximum file size to search in bytes (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 0 = unlimited) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when all patterns are lowercase (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* The shared instance surface implemented by `FileFinder` in both
* `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* Both packages must implement this identically. Only instance members belong
* here. Static helpers (`create`, `isAvailable`, `ensureLoaded`,
* `healthCheckStatic`) are package-specific and intentionally excluded.
*/
export interface FileFinderApi {
/** Whether the instance has been destroyed. */
readonly isDestroyed: boolean;
/** Destroy and free all native resources. */
destroy(): void;
/** Fuzzy file search. */
fileSearch(query: string, options?: SearchOptions): Result<SearchResult>;
/** Glob-only filtering (no fuzzy matching). */
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
/** Fuzzy directory search. */
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
/** Fuzzy search over files and directories interleaved by score. */
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
/** Content search (live grep). */
grep(query: string, options?: GrepOptions): Result<GrepResult>;
/** Multi-pattern OR content search (Aho-Corasick). */
multiGrep(options: MultiGrepOptions): Result<GrepResult>;
/** Trigger an async rescan of the indexed directory. */
scanFiles(): Result<void>;
/** Whether a scan is currently in progress. */
isScanning(): boolean;
/** The root directory being indexed. */
getBasePath(): Result<string | null>;
/** Current scan progress snapshot. */
getScanProgress(): Result<ScanProgress>;
/**
* Wait for the initial file scan to complete.
*
* Non-blocking: polls `isScanning` and yields to the event loop between
* checks, so other async work keeps running while waiting.
*/
waitForScan(timeoutMs?: number): Promise<Result<boolean>>;
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer `waitForScan` unless
* you specifically need synchronous blocking behaviour.
*/
waitForScanBlocking(timeoutMs?: number): Result<boolean>;
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*/
waitForIndexReady(timeoutMs?: number): Promise<Result<boolean>>;
/** Restart indexing in a new directory. */
reindex(newPath: string): Result<void>;
/** Refresh the git status cache. Returns the number of updated files. */
refreshGitStatus(): Result<number>;
/** Record that `selectedFilePath` was chosen for `query`. */
trackQuery(query: string, selectedFilePath: string): Result<boolean>;
/** Get a historical query by offset (0 = most recent). */
getHistoricalQuery(offset: number): Result<string | null>;
/** Health/diagnostics information for this instance. */
healthCheck(testPath?: string): Result<HealthCheck>;
}