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
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
examples/
|
||||
test/
|
||||
@@ -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
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Executable
+276
@@ -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);
|
||||
});
|
||||
Executable
+392
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+17
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
})();
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
3.10
|
||||
@@ -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+.
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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__",
|
||||
]
|
||||
@@ -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]: ...
|
||||
@@ -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
|
||||
Generated
+208
@@ -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" },
|
||||
]
|
||||
@@ -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/`
|
||||
@@ -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
@@ -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(" ");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user