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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:27:16 +08:00
commit d5f207424b
328 changed files with 87463 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
/**
* Binary resolution utilities for fff-node
*
* Resolves the native library from:
* 1. Platform-specific npm package (e.g. @ff-labs/fff-bin-darwin-arm64)
* 2. Local dev build (target/release or target/debug)
*/
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getLibFilename, getNpmPackageName } from "./platform.js";
/**
* Get the current file's directory
*/
function getCurrentDir(): string {
const url = import.meta.url;
if (url.startsWith("file://")) {
return dirname(fileURLToPath(url));
}
return dirname(url);
}
/**
* Get the package root directory
*/
function getPackageDir(): string {
const currentDir = getCurrentDir();
// In dev: src/ -> package root
// In dist: dist/src/ -> package root
// We look for package.json to find the actual root
let dir = currentDir;
for (let i = 0; i < 5; i++) {
if (existsSync(join(dir, "package.json"))) {
try {
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
if (pkg.name === "@ff-labs/fff-node") {
return dir;
}
} catch {
// Not our package.json, keep going up
}
}
dir = dirname(dir);
}
// Fallback: assume we're one level deep in src/
return dirname(currentDir);
}
/**
* Check if the binary exists in any known location
*/
export function binaryExists(): boolean {
return findBinary() !== null;
}
/**
* Try to resolve the binary from the platform-specific npm package.
*
* When users install @ff-labs/fff-node, npm automatically installs the matching
* optionalDependency (e.g. @ff-labs/fff-bin-darwin-arm64). We resolve the binary
* path by requiring that package's package.json and looking for the binary
* in the same directory.
*/
function resolveFromNpmPackage(): string | null {
const packageName = getNpmPackageName();
try {
// Use createRequire to resolve the platform package's location
const require = createRequire(join(getPackageDir(), "package.json"));
const packageJsonPath = require.resolve(`${packageName}/package.json`);
const packageDir = dirname(packageJsonPath);
const binaryPath = join(packageDir, getLibFilename());
if (existsSync(binaryPath)) {
return binaryPath;
}
} catch {
// Package not installed - this is expected on unsupported platforms
// or when installed without optional dependencies
}
return null;
}
/**
* Get the development binary path (for local development)
*/
function getDevBinaryPath(): string | null {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
const possiblePaths = [
join(workspaceRoot, "target", "release", getLibFilename()),
join(workspaceRoot, "target", "debug", getLibFilename()),
];
for (const path of possiblePaths) {
if (existsSync(path)) {
return path;
}
}
return null;
}
function isDevWorkspace(): boolean {
const packageDir = getPackageDir();
const workspaceRoot = join(packageDir, "..", "..");
return existsSync(join(workspaceRoot, "Cargo.toml"));
}
/**
* Find the native library binary.
*
* Resolution order:
* - Dev workspace: local dev build first, then npm package
* - Production: npm package first, then dev build
*
* @returns Absolute path to the library, or null if not found
*/
export function findBinary(): string | null {
if (isDevWorkspace()) {
// 1. Local bin/ directory (populated by `make prepare-node`)
const binPath = join(getPackageDir(), "bin", getLibFilename());
if (existsSync(binPath)) return binPath;
// 2. Local dev build (target/release or target/debug)
const devPath = getDevBinaryPath();
if (devPath) return devPath;
// 3. Fallback to npm package
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
return null;
}
// Production: npm package first
const npmPath = resolveFromNpmPackage();
if (npmPath) return npmPath;
// Fallback: local dev build (e.g. user built from source)
return getDevBinaryPath();
}
+609
View File
@@ -0,0 +1,609 @@
// ----------------------------------------------------------------------------
// GENERATED FILE - DO NOT EDIT.
// Copied from: packages/shared/fff-api.ts
// Run make sync-js-api from the repo root to regenerate.
// ----------------------------------------------------------------------------
/**
* The shared public API surface for the fff file finder, implemented identically
* by `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* This file is the single source of truth for every type, helper, and the
* `FileFinderApi` interface that crosses the package boundary. It is copied
* verbatim into each package's `src/fff-api.ts` by `make sync-api`.
*
* Anything that is not part of the public API (FFI struct layouts, binary
* loading, platform detection, etc.) stays as per-package internal
* implementation and must NOT live here.
*
* Keep this file self-contained: it must not import from any package-local
* module, since each package compiles its own copy.
*/
/**
* Result type for all operations - follows the Result pattern
*/
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
/**
* Helper to create a successful result
*/
export function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
/**
* Helper to create an error result
*/
export function err<T>(error: string): Result<T> {
return { ok: false, error };
}
/**
* Initialization options for the file finder
*/
export interface InitOptions {
/** Base directory to index (required) */
basePath: string;
/** Path to frecency database (optional, omit to skip frecency initialization) */
frecencyDbPath?: string;
/** Path to query history database (optional, omit to skip query tracker initialization) */
historyDbPath?: string;
/** @deprecated no-op */
useUnsafeNoLock?: boolean;
/**
* Disable mmap cache warmup after the initial scan. When mmap cache is
* enabled (the default), the first grep search is as fast as subsequent
* ones at the cost of a longer scan time and higher initial memory usage.
*/
disableMmapCache?: boolean;
/**
* Disable the content index built after the initial scan.
* Content indexing enables faster content-aware filtering during grep.
*/
disableContentIndexing?: boolean;
/**
* Disable the background file-system watcher. When the watcher is
* disabled, files are scanned once but not monitored for changes.
* (default: false)
*/
disableWatch?: boolean;
/** enables optimizations for AI agent assistants. Provide as true if running via mcp/agent */
aiMode?: boolean;
/**
* Path to the tracing log file. When set, the shared FFF tracing subscriber
* is installed on first init and file output is written here. Omit to leave
* logging uninitialized.
*/
logFilePath?: string;
/**
* Log level for the tracing subscriber: "trace", "debug", "info", "warn",
* or "error". Defaults to "info". Ignored when `logFilePath` is not set.
*/
logLevel?: "trace" | "debug" | "info" | "warn" | "error";
/**
* Override for the content cache file-count cap. When omitted, the picker
* auto-sizes the budget from the final scanned file count.
*/
cacheBudgetMaxFiles?: number;
/** Override for the content cache byte cap. See `cacheBudgetMaxFiles`. */
cacheBudgetMaxBytes?: number;
/** Override for the per-file byte cap in the content cache. */
cacheBudgetMaxFileSize?: number;
/**
* Allow indexing the filesystem root (`/`).
* Off by default, having fff instance at the large folder will generally require
* file watcher and indexing which will consume a lot of resources if performed uncontrolled
**/
enableFsRootScanning?: boolean;
/** Allow indexing the user's home directory. Same trade-off as `enableFsRootScanning`. */
enableHomeDirScanning?: boolean;
/** Follow symlinks for directories */
followSymlinks?: boolean;
}
/**
* Search options for fuzzy file search
*/
export interface SearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for deprioritization in results) */
currentFile?: string;
/** Combo boost score multiplier (default: 100) */
comboBoostMultiplier?: number;
/** Minimum combo count for boost (default: 3) */
minComboCount?: number;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Options for `glob`, the constraint-only search.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*/
export interface GlobOptions {
/** Maximum threads for parallel filtering (0 = auto). */
maxThreads?: number;
/** Current file path (for deprioritization in results). */
currentFile?: string;
/** Page index for pagination (default: 0). */
pageIndex?: number;
/** Page size for pagination (default: 100). */
pageSize?: number;
}
/**
* A file item in search results
*/
export interface FileItem {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Frecency score based on access patterns */
accessFrecencyScore: number;
/** Frecency score based on modification time */
modificationFrecencyScore: number;
/** Combined frecency score */
totalFrecencyScore: number;
/** Git status: 'clean', 'modified', 'untracked', 'staged_new', etc. */
gitStatus: string;
}
/**
* Score breakdown for a search result
*/
export interface Score {
/** Total combined score */
total: number;
/** Base fuzzy match score */
baseScore: number;
/** Bonus for filename match */
filenameBonus: number;
/** Bonus for special filenames (index.ts, main.rs, etc.) */
specialFilenameBonus: number;
/** Boost from frecency */
frecencyBoost: number;
/** Penalty for distance in path */
distancePenalty: number;
/** Penalty if this is the current file */
currentFilePenalty: number;
/** Boost from query history combo matching */
comboMatchBoost: number;
/** Whether this was an exact match */
exactMatch: boolean;
/** Type of match: 'fuzzy', 'exact', 'prefix', etc. */
matchType: string;
}
/**
* Location in file (from query like "file.ts:42")
*/
export type Location =
| { type: "line"; line: number }
| { type: "position"; line: number; col: number }
| {
type: "range";
start: { line: number; col: number };
end: { line: number; col: number };
};
/**
* Search result from fuzzy file search
*/
export interface SearchResult {
/** Matched file items */
items: FileItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of files that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Location parsed from query (e.g., "file.ts:42:10") */
location?: Location;
}
/**
* A directory item in search results
*/
export interface DirItem {
/** Path relative to the indexed directory (e.g., "src/components/") */
relativePath: string;
/** Last path segment (e.g., "components/" for "src/components/") */
dirName: string;
/** Maximum access frecency score among direct child files */
maxAccessFrecency: number;
}
/**
* Search options for directory search (subset of SearchOptions)
*/
export interface DirSearchOptions {
/** Maximum threads for parallel search (0 = auto) */
maxThreads?: number;
/** Current file path (for distance scoring) */
currentFile?: string;
/** Page index for pagination (default: 0) */
pageIndex?: number;
/** Page size for pagination (default: 100) */
pageSize?: number;
}
/**
* Search result from fuzzy directory search
*/
export interface DirSearchResult {
/** Matched directory items */
items: DirItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of directories that matched */
totalMatched: number;
/** Total number of indexed directories */
totalDirs: number;
}
/**
* A single item in a mixed (files + directories) search result
*/
export type MixedItem =
| { type: "file"; item: FileItem }
| { type: "directory"; item: DirItem };
/**
* Search result from mixed (files + directories) fuzzy search.
* Items are interleaved by total score in descending order.
*/
export interface MixedSearchResult {
/** Matched items (files and directories interleaved by score) */
items: MixedItem[];
/** Corresponding scores for each item */
scores: Score[];
/** Total number of items (files + dirs) that matched */
totalMatched: number;
/** Total number of indexed files */
totalFiles: number;
/** Total number of indexed directories */
totalDirs: number;
/** Location parsed from query */
location?: Location;
}
/**
* Scan progress information
*/
export interface ScanProgress {
/** Number of files scanned so far */
scannedFilesCount: number;
/** Whether a scan is currently in progress */
isScanning: boolean;
/** Whether the background file watcher is ready */
isWatcherReady: boolean;
/** Whether the warmup/bigram phase has completed */
isWarmupComplete: boolean;
}
/**
* Database health information
*/
export interface DbHealth {
/** Path to the database */
path: string;
/** Size of the database on disk in bytes */
diskSize: number;
}
/**
* Health check result
*/
export interface HealthCheck {
/** Library version */
version: string;
/** Git integration status */
git: {
/** Whether git2 library is available */
available: boolean;
/** Whether a git repository was found */
repositoryFound: boolean;
/** Git working directory path */
workdir?: string;
/** libgit2 version string */
libgit2Version: string;
/** Error message if git detection failed */
error?: string;
};
/** File picker status */
filePicker: {
/** Whether the file picker is initialized */
initialized: boolean;
/** Base path being indexed */
basePath?: string;
/** Whether a scan is in progress */
isScanning?: boolean;
/** Number of indexed files */
indexedFiles?: number;
/** Error message if there's an issue */
error?: string;
};
/** Frecency database status */
frecency: {
/** Whether frecency tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
/** Query tracker status */
queryTracker: {
/** Whether query tracking is initialized */
initialized: boolean;
/** Database health information */
dbHealthcheck?: DbHealth;
/** Error message if there's an issue */
error?: string;
};
}
/**
* Grep search mode
*/
export type GrepMode = "plain" | "regex" | "fuzzy";
/**
* Opaque pagination cursor for grep results.
* Pass this to `GrepOptions.cursor` to fetch the next page.
* Do not construct or modify this — use the `nextCursor` from a previous `GrepResult`.
*/
export interface GrepCursor {
/** @internal */
readonly __brand: "GrepCursor";
/** @internal */
readonly _offset: number;
}
/**
* @internal Create a GrepCursor from a raw file offset.
*/
export function createGrepCursor(offset: number): GrepCursor {
return { __brand: "GrepCursor" as const, _offset: offset };
}
/**
* Options for live grep (content search)
*
* Files are searched sequentially in frecency order (most recently/frequently
* accessed first). The engine returns a `nextCursor` for fetching the next page.
*/
export interface GrepOptions {
/** Maximum file size to search in bytes. Files larger than this are skipped. (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 200) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when the query is all lowercase, case-sensitive otherwise (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/** Search mode (default: "plain") */
mode?: GrepMode;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. Let callers re-rank defs first
* without a TS-side regex port. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* A single grep match with file and line information
*/
export interface GrepMatch {
/** Path relative to the indexed directory */
relativePath: string;
/** File name only */
fileName: string;
/** Git status */
gitStatus: string;
/** File size in bytes */
size: number;
/** Last modified timestamp (Unix seconds) */
modified: number;
/** Whether the file is binary */
isBinary: boolean;
/** Combined frecency score */
totalFrecencyScore: number;
/** Access-based frecency score */
accessFrecencyScore: number;
/** Modification-based frecency score */
modificationFrecencyScore: number;
/** 1-based line number of the match */
lineNumber: number;
/** 0-based byte column of first match start */
col: number;
/** Absolute byte offset of the matched line from file start */
byteOffset: number;
/** The matched line text (may be truncated) */
lineContent: string;
/** Byte offset pairs [start, end] within lineContent for highlighting */
matchRanges: [number, number][];
/** Fuzzy match score (only in fuzzy mode) */
fuzzyScore?: number;
/** Lines before the match (context). Empty array when context is 0. */
contextBefore?: string[];
/** Lines after the match (context). Empty array when context is 0. */
contextAfter?: string[];
/** Whether this line is a code definition (only populated when `classifyDefinitions: true`). */
isDefinition?: boolean;
}
/**
* Result from a grep search
*/
export interface GrepResult {
/** Matched items with file and line information. At most `max_matches_per_file`. */
items: GrepMatch[];
/** Total number of matches collected (always equal to items.length). */
totalMatched: number;
/** Number of files actually opened and searched in this call */
totalFilesSearched: number;
/** Total number of indexed files (before any filtering) */
totalFiles: number;
/** Number of files eligible for search after filtering out binary files, oversized files, and constraint mismatches */
filteredFileCount: number;
/**
* Cursor for the next page, or `null` if all eligible files have been searched.
* Pass this as `GrepOptions.cursor` to continue from where this call left off.
*/
nextCursor: GrepCursor | null;
/** When regex mode fails to compile the pattern, the engine falls back to literal matching and this field contains the compilation error */
regexFallbackError?: string;
}
/**
* Options for multi-pattern grep (Aho-Corasick multi-needle search)
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated Aho-Corasick multi-pattern matching.
*/
export interface MultiGrepOptions {
/** Patterns to search for (OR logic — matches lines containing any pattern) */
patterns: string[];
/** File constraints like "*.rs" or "/src/" */
constraints?: string;
/** Maximum file size to search in bytes (default: 10MB) */
maxFileSize?: number;
/** Maximum matching lines to collect from a single file (default: 0 = unlimited) */
maxMatchesPerFile?: number;
/** Smart case: case-insensitive when all patterns are lowercase (default: true) */
smartCase?: boolean;
/**
* Pagination cursor from a previous `GrepResult.nextCursor`.
* Omit (or pass `null`) for the first page.
*/
cursor?: GrepCursor | null;
/**
* Maximum wall-clock time in milliseconds to spend searching before returning
* partial results. 0 = unlimited. (default: 0)
*/
timeBudgetMs?: number;
/** Number of context lines to include before each match (default: 0) */
beforeContext?: number;
/** Number of context lines to include after each match (default: 0) */
afterContext?: number;
/** Maximum matches to return in this page across all files (default: 50) */
pageSize?: number;
/**
* When true, classify each match line as a code definition (struct/fn/class/...)
* and expose it via `GrepMatch.isDefinition`. (default: false)
*/
classifyDefinitions?: boolean;
}
/**
* The shared instance surface implemented by `FileFinder` in both
* `@ff-labs/fff-node` and `@ff-labs/fff-bun`.
*
* Both packages must implement this identically. Only instance members belong
* here. Static helpers (`create`, `isAvailable`, `ensureLoaded`,
* `healthCheckStatic`) are package-specific and intentionally excluded.
*/
export interface FileFinderApi {
/** Whether the instance has been destroyed. */
readonly isDestroyed: boolean;
/** Destroy and free all native resources. */
destroy(): void;
/** Fuzzy file search. */
fileSearch(query: string, options?: SearchOptions): Result<SearchResult>;
/** Glob-only filtering (no fuzzy matching). */
glob(pattern: string, options?: GlobOptions): Result<SearchResult>;
/** Fuzzy directory search. */
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult>;
/** Fuzzy search over files and directories interleaved by score. */
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult>;
/** Content search (live grep). */
grep(query: string, options?: GrepOptions): Result<GrepResult>;
/** Multi-pattern OR content search (Aho-Corasick). */
multiGrep(options: MultiGrepOptions): Result<GrepResult>;
/** Trigger an async rescan of the indexed directory. */
scanFiles(): Result<void>;
/** Whether a scan is currently in progress. */
isScanning(): boolean;
/** The root directory being indexed. */
getBasePath(): Result<string | null>;
/** Current scan progress snapshot. */
getScanProgress(): Result<ScanProgress>;
/**
* Wait for the initial file scan to complete.
*
* Non-blocking: polls `isScanning` and yields to the event loop between
* checks, so other async work keeps running while waiting.
*/
waitForScan(timeoutMs?: number): Promise<Result<boolean>>;
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer `waitForScan` unless
* you specifically need synchronous blocking behaviour.
*/
waitForScanBlocking(timeoutMs?: number): Result<boolean>;
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*/
waitForIndexReady(timeoutMs?: number): Promise<Result<boolean>>;
/** Restart indexing in a new directory. */
reindex(newPath: string): Result<void>;
/** Refresh the git status cache. Returns the number of updated files. */
refreshGitStatus(): Result<number>;
/** Record that `selectedFilePath` was chosen for `query`. */
trackQuery(query: string, selectedFilePath: string): Result<boolean>;
/** Get a historical query by offset (0 = most recent). */
getHistoricalQuery(offset: number): Result<string | null>;
/** Health/diagnostics information for this instance. */
healthCheck(testPath?: string): Result<HealthCheck>;
}
File diff suppressed because it is too large Load Diff
+623
View File
@@ -0,0 +1,623 @@
/**
* FileFinder - High-level API for the fff file finder
*
* This class provides a type-safe, ergonomic API for file finding operations.
* Each instance owns an independent native file picker that can be created
* and destroyed independently. Multiple instances can coexist.
*
* All methods return Result types for explicit error handling.
*/
import {
ensureLoaded,
ffiCreate,
ffiDestroy,
ffiGetBasePath,
ffiGetHistoricalQuery,
ffiGetScanProgress,
ffiGlob,
ffiHealthCheck,
ffiIsScanning,
ffiLiveGrep,
ffiMultiGrep,
ffiRefreshGitStatus,
ffiRestartIndex,
ffiScanFiles,
ffiSearch,
ffiSearchDirectories,
ffiSearchMixed,
ffiTrackQuery,
ffiWaitForScan,
isAvailable,
type NativeHandle,
} from "./ffi.js";
import type {
DirSearchOptions,
DirSearchResult,
FileFinderApi,
GlobOptions,
GrepOptions,
GrepResult,
HealthCheck,
InitOptions,
MixedSearchResult,
MultiGrepOptions,
Result,
ScanProgress,
SearchOptions,
SearchResult,
} from "./fff-api.js";
import { err } from "./fff-api.js";
/**
* FileFinder - Fast file finder with fuzzy search
*
* Each instance is backed by an independent native file picker. Create as many
* as you need and destroy them when done.
*
* @example
*
* ```ts
* import { FileFinder } from "@ff-labs/fff-node";
*
* // Create an instance
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (!finder.ok) {
* console.error(finder.error);
* process.exit(1);
* }
*
* // Wait for initial scan
* await finder.value.waitForScan(5000);
*
* // Search for files
* const search = finder.value.search("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
* }
* }
*
* // Cleanup
* finder.value.destroy();
* ```
*/
export class FileFinder implements FileFinderApi {
private handle: NativeHandle | null;
private constructor(handle: NativeHandle) {
this.handle = handle;
}
/**
* Create a new file finder instance.
*
* @param options - Initialization options
* @returns Result containing the new FileFinder instance or an error
*
* @example
* ```typescript
* // Basic initialization
* const finder = FileFinder.create({ basePath: "/path/to/project" });
*
* // With custom database paths
* const finder = FileFinder.create({
* basePath: "/path/to/project",
* frecencyDbPath: "/custom/frecency.mdb",
* historyDbPath: "/custom/history.mdb",
* });
* ```
*/
static create(options: InitOptions): Result<FileFinder> {
const result = ffiCreate(
options.basePath,
options.frecencyDbPath ?? "",
options.historyDbPath ?? "",
options.useUnsafeNoLock ?? false,
!(options.disableMmapCache ?? false),
!(options.disableContentIndexing ?? options.disableMmapCache ?? false),
!(options.disableWatch ?? false),
options.aiMode ?? false,
options.logFilePath ?? "",
options.logLevel ?? "",
options.cacheBudgetMaxFiles ?? 0,
options.cacheBudgetMaxBytes ?? 0,
options.cacheBudgetMaxFileSize ?? 0,
options.enableFsRootScanning ?? false,
options.enableHomeDirScanning ?? false,
options.followSymlinks ?? false,
);
if (!result.ok) {
return result;
}
return { ok: true, value: new FileFinder(result.value) };
}
/**
* Destroy and clean up all resources.
*
* Call this when you're done using the file finder to free memory
* and stop background file watching. After calling this, the instance
* must not be used again.
*/
destroy(): void {
if (this.handle !== null) {
ffiDestroy(this.handle);
this.handle = null;
}
}
/**
* Check if this instance has been destroyed.
*/
get isDestroyed(): boolean {
return this.handle === null;
}
/**
* Guard that returns an error if the instance has been destroyed.
*/
private ensureAlive(): Result<NativeHandle> {
if (this.handle === null) {
return err("FileFinder instance has been destroyed.");
}
return { ok: true, value: this.handle };
}
/**
* Search for files matching the query.
*
* The query supports fuzzy matching and special syntax:
* - `foo bar` - Match files containing "foo" and "bar"
* - `src/` - Match files in src directory
* - `file.ts:42` - Match file.ts with line 42
* - `file.ts:42:10` - Match file.ts with line 42, column 10
*
* @param query - Search query string
* @param options - Search options
* @returns Search results with matched files and scores
*
* @example
* ```typescript
* const result = finder.search("main.ts", { pageSize: 10 });
* if (result.ok) {
* console.log(`Found ${result.value.totalMatched} files`);
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
fileSearch(query: string, options?: SearchOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearch(
guard.value,
query,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
options?.comboBoostMultiplier ?? 0,
options?.minComboCount ?? 0,
);
}
/**
* Filters files using glob wildcard expression.
*
* The pattern is applied as a single pass SIMD optimized prefiltering
* without any fuzzy matching involved. Faster and 100% compatible to npm `glob`.
*
* @param pattern - Glob pattern (required, non-empty)
* @param options - Glob search options (pagination, max threads, current file)
* @returns Search results with files matching the glob
*
* @example
* ```typescript
* const result = finder.glob("**\/*.rs", { pageSize: 100 });
* if (result.ok) {
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
glob(pattern: string, options?: GlobOptions): Result<SearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGlob(
guard.value,
pattern,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
);
}
/**
* Search for directories matching the query.
*
* @param query - Search query string
* @param options - Directory search options
* @returns Search results with matched directories and scores
*
* @example
* ```typescript
* const result = finder.directorySearch("src/comp", { pageSize: 10 });
* if (result.ok) {
* console.log(`Found ${result.value.totalMatched} directories`);
* for (const item of result.value.items) {
* console.log(item.relativePath);
* }
* }
* ```
*/
directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearchDirectories(
guard.value,
query,
options?.currentFile ?? null,
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
);
}
/**
* Search for files and directories matching the query (mixed results).
*
* Results are interleaved by total score in descending order, mixing
* both file and directory items.
*
* @param query - Search query string
* @param options - Search options
* @returns Mixed search results with files and directories interleaved by score
*
* @example
* ```typescript
* const result = finder.mixedSearch("main", { pageSize: 20 });
* if (result.ok) {
* for (const entry of result.value.items) {
* if (entry.type === "file") {
* console.log(`File: ${entry.item.relativePath}`);
* } else {
* console.log(`Dir: ${entry.item.relativePath}`);
* }
* }
* }
* ```
*/
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiSearchMixed(
guard.value,
query,
options?.currentFile ?? "",
options?.maxThreads ?? 0,
options?.pageIndex ?? 0,
options?.pageSize ?? 0,
options?.comboBoostMultiplier ?? 0,
options?.minComboCount ?? 0,
);
}
/**
* Search file contents (live grep).
*
* Searches through the contents of indexed files using the specified mode:
* - `"plain"` (default): SIMD-accelerated literal text matching
* - `"regex"`: Regular expression matching
* - `"fuzzy"`: Smith-Waterman fuzzy matching per line
*
* Supports pagination for large result sets. The result includes a `nextCursor`
* that can be passed back to fetch the next page.
*
* The query also supports constraint syntax:
* - `*.ts pattern` - Only search in TypeScript files
* - `src/ pattern` - Only search in the src directory
*
* @param query - Search query string
* @param options - Grep options (mode, pagination, limits)
* @returns Grep results with matched lines and file metadata
*
* @example
* ```typescript
* // First page
* const result = finder.grep("TODO", { mode: "plain" });
* if (result.ok) {
* for (const match of result.value.items) {
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
* }
* // Fetch next page
* if (result.value.nextCursor) {
* const page2 = finder.grep("TODO", {
* cursor: result.value.nextCursor,
* });
* }
* }
* ```
*/
grep(query: string, options?: GrepOptions): Result<GrepResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiLiveGrep(
guard.value,
query,
options?.mode ?? "plain",
options?.maxFileSize ?? 0,
options?.maxMatchesPerFile ?? 0,
options?.smartCase ?? true,
options?.cursor?._offset ?? 0,
options?.pageSize ?? 0,
options?.timeBudgetMs ?? 0,
options?.beforeContext ?? 0,
options?.afterContext ?? 0,
options?.classifyDefinitions ?? false,
);
}
/**
* Multi-pattern OR search using Aho-Corasick.
*
* Searches for lines matching ANY of the provided patterns using
* SIMD-accelerated multi-needle matching. Faster than regex alternation
* for literal text searches.
*
* Supports pagination. The result includes a `nextCursor` that can be
* passed back to fetch the next page.
*
* @param options - Multi-grep options including patterns and optional constraints
* @returns Grep results with matched lines and file metadata
*
* @example
* ```typescript
* const result = finder.multiGrep({
* patterns: ["VideoFrame", "video_frame", "PreloadedImage"],
* });
* if (result.ok) {
* for (const match of result.value.items) {
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
* }
* }
* ```
*/
multiGrep(options: MultiGrepOptions): Result<GrepResult> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
if (!options.patterns || options.patterns.length === 0) {
return err("patterns array must have at least 1 element");
}
return ffiMultiGrep(
guard.value,
options.patterns.join("\n"),
options.constraints ?? "",
options.maxFileSize ?? 0,
options.maxMatchesPerFile ?? 0,
options.smartCase ?? true,
options.cursor?._offset ?? 0,
options.pageSize ?? 0,
options.timeBudgetMs ?? 0,
options.beforeContext ?? 0,
options.afterContext ?? 0,
options.classifyDefinitions ?? false,
);
}
/**
* Trigger a rescan of the indexed directory.
*
* This is useful after major file system changes that the
* background watcher might have missed.
*/
scanFiles(): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiScanFiles(guard.value);
}
/**
* Check if a scan is currently in progress.
*/
isScanning(): boolean {
if (this.handle === null) return false;
return ffiIsScanning(this.handle);
}
/**
* Get the base path of the file picker (the root directory being indexed).
*/
getBasePath(): Result<string | null> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetBasePath(guard.value);
}
/**
* Get the current scan progress.
*/
getScanProgress(): Result<ScanProgress> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetScanProgress(guard.value) as Result<ScanProgress>;
}
/**
* Wait for the initial file scan to complete.
* Non-blocking: polls `isScanning` and yields to the event loop between checks.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*
* @example
* ```typescript
* const finder = FileFinder.create({ basePath: "/path/to/project" });
* if (finder.ok) {
* const completed = await finder.value.waitForScan(10000);
* if (!completed.ok || !completed.value) {
* console.warn("Scan did not complete in time");
* }
* }
* ```
*/
async waitForScan(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const deadline = Date.now() + timeoutMs;
while (this.isScanning()) {
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return { ok: true, value: true };
}
/**
* Wait for the initial file scan to complete, blocking the calling thread.
*
* Backed by the native `fff_wait_for_scan` call. Prefer {@link waitForScan}
* unless you specifically need synchronous blocking behaviour.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if scan completed, false if timed out
*/
waitForScanBlocking(timeoutMs: number = 5000): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiWaitForScan(guard.value, timeoutMs);
}
/**
* Wait until the index is fully ready: the scan has finished and the warmup
* (content indexing / bigram) phase has completed.
*
* Non-blocking: polls `getScanProgress` and yields to the event loop.
*
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
* @returns true if the index became ready, false if timed out
*/
async waitForIndexReady(timeoutMs: number = 5000): Promise<Result<boolean>> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
const deadline = Date.now() + timeoutMs;
while (true) {
const progress = this.getScanProgress();
if (!progress.ok) return progress;
if (!progress.value.isScanning && progress.value.isWarmupComplete) {
return { ok: true, value: true };
}
if (Date.now() >= deadline) {
return { ok: true, value: false };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/**
* Change the indexed directory to a new path.
*
* This stops the current file watcher and starts indexing the new directory.
*
* @param newPath - New directory path to index
*/
reindex(newPath: string): Result<void> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRestartIndex(guard.value, newPath);
}
/**
* Refresh the git status cache.
*
* @returns Number of files with updated git status
*/
refreshGitStatus(): Result<number> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiRefreshGitStatus(guard.value);
}
/**
* Track query completion for smart suggestions.
*
* Call this when a user selects a file from search results.
* This helps improve future search rankings for similar queries.
*
* @param query - The search query that was used
* @param selectedFilePath - The file path that was selected
*/
trackQuery(query: string, selectedFilePath: string): Result<boolean> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiTrackQuery(guard.value, query, selectedFilePath);
}
/**
* Get a historical query by offset.
*
* @param offset - Offset from most recent (0 = most recent)
* @returns The historical query string, or null if not found
*/
getHistoricalQuery(offset: number): Result<string | null> {
const guard = this.ensureAlive();
if (!guard.ok) return guard;
return ffiGetHistoricalQuery(guard.value, offset);
}
/**
* Get health check information.
*
* Useful for debugging and verifying the file finder is working correctly.
*
* @param testPath - Optional path to test git repository detection
*/
healthCheck(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(this.handle, testPath || "") as Result<HealthCheck>;
}
/**
* Check if the native library is available.
*/
static isAvailable(): boolean {
return isAvailable();
}
/**
* Ensure the native library is loaded.
*
* Loads the native library from the platform-specific npm package
* or a local dev build. Throws if the binary is not found.
*/
static ensureLoaded(): void {
ensureLoaded();
}
/**
* Get a health check without requiring an instance.
*
* Returns limited info (version + git only, no picker/frecency/query data).
*
* @param testPath - Optional path to test git repository detection
*/
static healthCheckStatic(testPath?: string): Result<HealthCheck> {
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* fff - Fast File Finder
*
* High-performance fuzzy file finder for Node.js, powered by Rust.
* Perfect for LLM agent tools that need to search through codebases.
*
* Each `FileFinder` instance is backed by an independent native file picker.
* Create as many as you need and destroy them when done.
*
* Uses ffi-rs to load the same native libfff_c binary used by @ff-labs/fff-bun.
*
* @example
* ```typescript
* import { FileFinder } from "@ff-labs/fff-node";
*
* // Create a file finder instance
* const result = FileFinder.create({ basePath: "/path/to/project" });
* if (!result.ok) {
* console.error(result.error);
* process.exit(1);
* }
* const finder = result.value;
*
* // Wait for initial scan
* await finder.waitForScan(5000);
*
* // Search for files
* const search = finder.fileSearch("main.ts");
* if (search.ok) {
* for (const item of search.value.items) {
* console.log(item.relativePath);
* }
* }
*
* // Cleanup when done
* finder.destroy();
* ```
*
* @packageDocumentation
*/
export {
binaryExists,
findBinary,
} from "./binary.js";
export { closeLibrary } from "./ffi.js";
export type {
DbHealth,
DirItem,
DirSearchOptions,
DirSearchResult,
FileFinderApi,
FileItem,
GrepCursor,
GrepMatch,
GrepMode,
GrepOptions,
GrepResult,
HealthCheck,
InitOptions,
Location,
MixedItem,
MixedSearchResult,
MultiGrepOptions,
Result,
ScanProgress,
Score,
SearchOptions,
SearchResult,
} from "./fff-api.js";
// Result helpers
export { err, ok } from "./fff-api.js";
export { FileFinder } from "./finder.js";
export {
getLibExtension,
getLibFilename,
getNpmPackageName,
getTriple,
} from "./platform.js";
+127
View File
@@ -0,0 +1,127 @@
/**
* Platform detection utilities for downloading the correct binary
*/
import { execSync } from "node:child_process";
/**
* Get the platform triple (e.g., "x86_64-unknown-linux-gnu")
*/
export function getTriple(): string {
const platform = process.platform;
const arch = process.arch;
let osName: string;
if (platform === "darwin") {
osName = "apple-darwin";
} else if (platform === "linux") {
osName = detectLinuxLibc();
} else if (platform === "win32") {
osName = "pc-windows-msvc";
} else {
throw new Error(`Unsupported platform: ${platform}`);
}
const archName = normalizeArch(arch);
return `${archName}-${osName}`;
}
/**
* Detect whether we're on musl or glibc Linux
*/
function detectLinuxLibc(): string {
let output = "";
try {
output = execSync("ldd --version 2>&1", {
encoding: "utf-8",
timeout: 5000,
});
} catch (e: unknown) {
const err = e as { stdout?: string | Buffer; stderr?: string | Buffer };
output = String(err?.stdout ?? "") + String(err?.stderr ?? "");
}
// ldd on musl can produce stdout with musl either with exit code 1 or 0
if (output.toLowerCase().includes("musl")) {
return "unknown-linux-musl";
}
return "unknown-linux-gnu";
}
/**
* Normalize architecture name to Rust target format
*/
function normalizeArch(arch: string): string {
switch (arch) {
case "x64":
case "amd64":
return "x86_64";
case "arm64":
return "aarch64";
case "arm":
return "arm";
default:
throw new Error(`Unsupported architecture: ${arch}`);
}
}
/**
* Get the library file extension for the current platform
*/
export function getLibExtension(): "dylib" | "so" | "dll" {
switch (process.platform) {
case "darwin":
return "dylib";
case "win32":
return "dll";
default:
return "so";
}
}
/**
* Get the library filename prefix (empty on Windows)
*/
export function getLibPrefix(): string {
return process.platform === "win32" ? "" : "lib";
}
/**
* Get the full library filename for the current platform
*/
export function getLibFilename(): string {
const prefix = getLibPrefix();
const ext = getLibExtension();
return `${prefix}fff_c.${ext}`;
}
/**
* Map from Rust target triple to npm platform package name.
* The @ff-labs/fff-bin-* packages contain the pre-built libfff_c
* shared library and are runtime-agnostic (used by both Bun and Node).
*/
const TRIPLE_TO_NPM_PACKAGE: Record<string, string> = {
"aarch64-apple-darwin": "@ff-labs/fff-bin-darwin-arm64",
"x86_64-apple-darwin": "@ff-labs/fff-bin-darwin-x64",
"x86_64-unknown-linux-gnu": "@ff-labs/fff-bin-linux-x64-gnu",
"aarch64-unknown-linux-gnu": "@ff-labs/fff-bin-linux-arm64-gnu",
"x86_64-unknown-linux-musl": "@ff-labs/fff-bin-linux-x64-musl",
"aarch64-unknown-linux-musl": "@ff-labs/fff-bin-linux-arm64-musl",
"x86_64-pc-windows-msvc": "@ff-labs/fff-bin-win32-x64",
"aarch64-pc-windows-msvc": "@ff-labs/fff-bin-win32-arm64",
};
/**
* Get the npm package name for the current platform's native binary.
*
* @returns Package name like "@ff-labs/fff-bin-darwin-arm64"
* @throws If the current platform is not supported
*/
export function getNpmPackageName(): string {
const triple = getTriple();
const packageName = TRIPLE_TO_NPM_PACKAGE[triple];
if (!packageName) {
throw new Error(`No npm package available for platform: ${triple}`);
}
return packageName;
}