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