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,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"]
|
||||
}
|
||||
Reference in New Issue
Block a user