diff --git a/Makefile b/Makefile index a0b6f529..a45962f2 100644 --- a/Makefile +++ b/Makefile @@ -14,10 +14,38 @@ SHELL := bash # string rather than the literal `-o` / `pipefail` tokens. .SHELLFLAGS := -o pipefail -ec -.PHONY: build build-c-lib install uninstall test test-rust test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-repos test-node-stress +.PHONY: build build-c-lib install uninstall test test-rust test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-repos test-node-stress sync-js-api sync-js-api-check all: format test lint +# Single source of truth for the shared FileFinder TS interface lives in +# packages/shared/fff-api.ts. tsc cannot import across a package's +# rootDir and the bun package publishes its raw src/, so the file is copied +# into each package instead of symlinked. +SYNC_API_SRC := packages/shared/fff-api.ts +SYNC_API_TARGETS := packages/fff-node/src/fff-api.ts packages/fff-bun/src/fff-api.ts +SYNC_API_BANNER := // ----------------------------------------------------------------------------\n// GENERATED FILE - DO NOT EDIT.\n// Source of truth: packages/shared/fff-api.ts\n// Run make sync-js-api from the repo root to regenerate.\n// ----------------------------------------------------------------------------\n\n + +sync-js-api: + @for target in $(SYNC_API_TARGETS); do \ + printf '$(SYNC_API_BANNER)' > "$$target"; \ + cat $(SYNC_API_SRC) >> "$$target"; \ + echo "synced: $$target"; \ + done + +sync-js-api-check: + @status=0; \ + for target in $(SYNC_API_TARGETS); do \ + tmp=$$(mktemp); \ + printf '$(SYNC_API_BANNER)' > "$$tmp"; \ + cat $(SYNC_API_SRC) >> "$$tmp"; \ + if ! cmp -s "$$tmp" "$$target"; then \ + echo "out of date: $$target (run make sync-js-api)"; status=1; \ + fi; \ + rm -f "$$tmp"; \ + done; \ + exit $$status + build: cargo build --release --features zlob @@ -123,13 +151,13 @@ test-version: test-setup nvim --headless -u tests/minimal_init.lua \ -c "PlenaryBustedFile tests/version_spec.lua" 2>&1 -prepare-bun: build +prepare-bun: build sync-js-api mkdir -p packages/fff-bun/bin cp target/release/libfff_c.dylib packages/fff-bun/bin/ 2>/dev/null || true; \ cp target/release/libfff_c.so packages/fff-bun/bin/ 2>/dev/null || true; \ cp target/release/fff_c.dll packages/fff-bun/bin/ 2>/dev/null || true -prepare-node: build +prepare-node: build sync-js-api mkdir -p packages/fff-node/bin cp target/release/libfff_c.dylib packages/fff-node/bin/ 2>/dev/null || true; \ cp target/release/libfff_c.so packages/fff-node/bin/ 2>/dev/null || true; \ @@ -142,6 +170,8 @@ test-bun: prepare-bun test-node: prepare-node cd packages/fff-node && npm run build && node test/e2e.mjs +test-js: test-bun test-node + # Bug pinning stress test script over fff-node for issue #515 # Just keep it untouched because it's good enough + some stress for SDK FFF_STRESS_ITERS ?= 50 diff --git a/crates/fff-core/src/bigram_filter.rs b/crates/fff-core/src/bigram_filter.rs index 46591c1e..18ba6f9c 100644 --- a/crates/fff-core/src/bigram_filter.rs +++ b/crates/fff-core/src/bigram_filter.rs @@ -110,9 +110,7 @@ impl BigramIndexBuilder { &slab[start..start + self.words] } - // `pub` (via `#[doc(hidden)]`) only for benchmarking - // External consumers should use `build_bigram_index` instead. - #[doc(hidden)] + #[doc(hidden)] // `pub` (via `#[doc(hidden)]`) only for benchmarking pub fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) { if content.len() < 2 { return; @@ -127,13 +125,6 @@ impl BigramIndexBuilder { let mut seen_consec = [0u64; 1024]; let mut seen_skip = [0u64; 1024]; - // Normalise each byte as we stream and carry a 2-byte history - // across iterations so each input byte is normalised exactly once - // even though it participates in up to three bigrams (as `cur`, - // then `prev`, then `skip_prev`). Benchmarked against a NEON - // pre-pass variant — the pre-pass needs a heap scratch per call, - // which kills throughput unless content is gigantic. Inline - // normalisation is the faster choice for realistic file sizes. let bytes = content; let len = bytes.len(); diff --git a/packages/fff-bun/README.md b/packages/fff-bun/README.md index 0845ac05..7b54687c 100644 --- a/packages/fff-bun/README.md +++ b/packages/fff-bun/README.md @@ -1,257 +1,102 @@ # fff - Fast File Finder -High-performance fuzzy file finder for Bun, powered by Rust. Perfect for LLM agent tools that need to search through codebases. +High-performance fuzzy file finder for Bun, powered by Rust. Extremely fast live file, content, and directory search with a typo-resistant algorithm. As well as regex, plain-text, multi-occurrence and typo-resistant content search. -## Features +Comes with built-in git status support, frecency access tracking, and a real-time file watcher, content indexing and many more! Designed for LLM agent tools that search through codebases or agentic RAG document search. -- **Blazing fast** - Rust-powered fuzzy search with parallel processing -- **Smart ranking** - Frecency-based scoring (frequency + recency) -- **Git-aware** - Shows file git status in results -- **Query history** - Learns from your search patterns -- **Type-safe** - Full TypeScript support with Result types +Faster than ripgrep & fzf on any workflow that runs more than once per process. ## Installation ```bash -bun add @ff-labs/bun +bun add @ff-labs/fff-bun ``` -The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bin-darwin-arm64`, `@ff-labs/fff-bin-linux-x64-gnu`). No GitHub downloads are needed. +The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bin-darwin-arm64`, `@ff-labs/fff-bin-linux-x64-gnu`) ### Supported Platforms -| Platform | Architecture | Package | -|----------|-------------|---------| -| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bin-darwin-arm64` | -| macOS | x64 (Intel) | `@ff-labs/fff-bin-darwin-x64` | -| Linux | x64 (glibc) | `@ff-labs/fff-bin-linux-x64-gnu` | -| Linux | ARM64 (glibc) | `@ff-labs/fff-bin-linux-arm64-gnu` | -| Linux | x64 (musl) | `@ff-labs/fff-bin-linux-x64-musl` | -| Linux | ARM64 (musl) | `@ff-labs/fff-bin-linux-arm64-musl` | -| Windows | x64 | `@ff-labs/fff-bin-win32-x64` | -| Windows | ARM64 | `@ff-labs/fff-bin-win32-arm64` | +| Platform | Architecture | Package | +| -------- | --------------------- | ----------------------------------- | +| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bin-darwin-arm64` | +| macOS | x64 (Intel) | `@ff-labs/fff-bin-darwin-x64` | +| Linux | x64 (glibc) | `@ff-labs/fff-bin-linux-x64-gnu` | +| Linux | ARM64 (glibc) | `@ff-labs/fff-bin-linux-arm64-gnu` | +| Linux | x64 (musl) | `@ff-labs/fff-bin-linux-x64-musl` | +| Linux | ARM64 (musl) | `@ff-labs/fff-bin-linux-arm64-musl` | +| Windows | x64 | `@ff-labs/fff-bin-win32-x64` | +| Windows | ARM64 | `@ff-labs/fff-bin-win32-arm64` | If the platform package isn't available, the postinstall script will attempt to download from GitHub releases as a fallback. ## Quick Start -```typescript -import { FileFinder } from "fff"; - -// Initialize with a directory -const result = FileFinder.init({ basePath: "/path/to/project" }); -if (!result.ok) { - console.error(result.error); - process.exit(1); -} - -// Wait for initial scan -FileFinder.waitForScan(5000); - -// Search for files -const search = FileFinder.search("main.ts"); -if (search.ok) { - for (const item of search.value.items) { - console.log(item.relativePath); - } -} - -// Cleanup when done -FileFinder.destroy(); -``` - -## API Reference - -### `FileFinder.init(options)` - -Initialize the file finder. - -```typescript -interface InitOptions { - basePath: string; // Directory to index (required) - frecencyDbPath?: string; // Frecency DB path (omit to skip frecency) - historyDbPath?: string; // History DB path (omit to skip query tracking) - useUnsafeNoLock?: boolean; // Faster but less safe DB mode -} - -const result = FileFinder.init({ basePath: "/my/project" }); -``` - -### `FileFinder.search(query, options?)` - -Search for files. - -```typescript -interface SearchOptions { - maxThreads?: number; // Parallel threads (0 = auto) - currentFile?: string; // Deprioritize this file - comboBoostMultiplier?: number; // Query history boost - minComboCount?: number; // Min history matches - pageIndex?: number; // Pagination offset - pageSize?: number; // Results per page -} - -const result = FileFinder.search("main.ts", { pageSize: 10 }); -if (result.ok) { - console.log(`Found ${result.value.totalMatched} files`); -} -``` - -### Query 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 with line and column - -### `FileFinder.trackAccess(filePath)` - -Track file access for frecency scoring. +Each `FileFinder` instance owns an independent native index. Create one, wait +for the initial scan, then run as many searches as you like. ```typescript -// Call when user opens a file -FileFinder.trackAccess("/path/to/file.ts"); -``` +import { FileFinder } from "@ff-labs/fff-bun"; -### `FileFinder.grep(query, options?)` +// Create an instance bound to a directory +const created = FileFinder.create({ basePath: "/path/to/project" }); +if (!created.ok) throw new Error(created.error); -Search file contents with SIMD-accelerated matching. +const finder = created.value; -```typescript -interface GrepOptions { - maxFileSize?: number; // Max file size in bytes (default: 10MB) - maxMatchesPerFile?: number; // Max matches per file (default: 200, set 0 to unlimited) - smartCase?: boolean; // Case-insensitive if all lowercase (default: true) - fileOffset?: number; // Pagination offset (default: 0) - pageLimit?: number; // Max matches to return (default: 50) - mode?: "plain" | "regex" | "fuzzy"; // Search mode (default: "plain") - timeBudgetMs?: number; // Time limit in ms, 0 = unlimited (default: 0) -} +// Wait for the initial scan (non-blocking) +await finder.waitForScan(5000); -// Plain text search -const result = FileFinder.grep("TODO", { pageLimit: 20 }); -if (result.ok) { - for (const match of result.value.items) { - console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`); +// 1. Fuzzy file search (typo resistant) +const files = finder.fileSearch("typescropt.ts", { pageSize: 10 }); +if (files.ok) { + for (const item of files.value.items) { + console.log(item.relativePath, item.gitStatus); } } -// Regex search -const regexResult = FileFinder.grep("fn\\s+\\w+", { mode: "regex" }); +// 2. Glob filter — no fuzzy matching, 100% compatible with npm `glob` +const globbed = finder.glob("src/**/*.ts"); +if (globbed.ok) console.log(`${globbed.value.totalMatched} TypeScript files`); -// Fuzzy search -const fuzzyResult = FileFinder.grep("imprt recat", { mode: "fuzzy" }); - -// Pagination -const page1 = FileFinder.grep("error"); -if (page1.ok && page1.value.nextCursor) { - const page2 = FileFinder.grep("error", { - cursor: page1.value.nextCursor, - }); +// 3. Content search (live grep) with pagination +const grep = finder.grep("TODO", { mode: "plain", pageSize: 20 }); +if (grep.ok) { + for (const m of grep.value.items) { + console.log(`${m.relativePath}:${m.lineNumber}: ${m.lineContent}`); + } } -// With file constraints -const tsOnly = FileFinder.grep("*.ts useState"); -const srcOnly = FileFinder.grep("src/ handleClick"); -``` - -### `FileFinder.trackQuery(query, selectedFile)` +// 4. Directory search based on the query (typo resistant) +const dirs = finder.directorySearch("components"); +if (dirs.ok) console.log(dirs.value.items.map((d) => d.relativePath)); -Track query completion for smart suggestions. - -```typescript -// Call when user selects a file from search -FileFinder.trackQuery("main", "/path/to/main.ts"); +// Free the resources when you don't need a file picker anymore +finder.destroy(); ``` -### `FileFinder.healthCheck(testPath?)` - -Get diagnostic information. - -```typescript -const health = FileFinder.healthCheck(); -if (health.ok) { - console.log(`Version: ${health.value.version}`); - console.log(`Indexed: ${health.value.filePicker.indexedFiles} files`); -} -``` -### Other Methods +## API Reference -- `FileFinder.grep(query, options?)` - Search file contents -- `FileFinder.scanFiles()` - Trigger rescan -- `FileFinder.isScanning()` - Check scan status -- `FileFinder.getScanProgress()` - Get scan progress -- `FileFinder.waitForScan(timeoutMs)` - Wait for scan -- `FileFinder.reindex(newPath)` - Change indexed directory -- `FileFinder.refreshGitStatus()` - Refresh git cache -- `FileFinder.getHistoricalQuery(offset)` - Get past queries -- `FileFinder.destroy()` - Cleanup resources +Verify the latest API in the local interface at [`./src/fff-api.ts`](./src/fff-api.ts). Every field and type is documented. -## Result Types +### Result Types All methods return a `Result` type for explicit error handling: + ```typescript -type Result = - | { ok: true; value: T } - | { ok: false; error: string }; +type Result = { ok: true; value: T } | { ok: false; error: string }; + +const result = finder.fileSearch("foo"); -const result = FileFinder.search("foo"); if (result.ok) { // result.value is SearchResult } else { - // result.error is string + // result.error is string error message } ``` -## Search Result Types - -```typescript -interface SearchResult { - items: FileItem[]; - scores: Score[]; - totalMatched: number; - totalFiles: number; - location?: Location; -} - -interface FileItem { - path: string; - relativePath: string; - fileName: string; - size: number; - modified: number; - gitStatus: string; // 'clean', 'modified', 'untracked', etc. -} -``` - -## Grep Result Types - -```typescript -interface GrepResult { - items: GrepMatch[]; - totalMatched: number; - totalFilesSearched: number; - totalFiles: number; - filteredFileCount: number; - nextCursor: GrepCursor | null; // Pass to options.cursor for next page - regexFallbackError?: string; // Set if regex was invalid -} - -interface GrepMatch { - path: string; - relativePath: string; - fileName: string; - gitStatus: string; - lineNumber: number; // 1-based - col: number; // 0-based byte column - byteOffset: number; // Absolute byte offset in file - lineContent: string; // The matched line text - matchRanges: [number, number][]; // Byte offsets for highlighting - fuzzyScore?: number; // Only in fuzzy mode -} -``` +This SDK calls a native compiled library for your platform at runtime. This is generally safe — fff is battle-tested and stable, and written in a memory-safe language — but there is a class of errors that can't be caught at the Bun/Node level. If you hit one, please report an issue! ## Building from Source @@ -268,7 +113,7 @@ cargo build --release -p fff-c # The binary will be at target/release/libfff_c.{so,dylib,dll} ``` -## CLI Tools +## CLI examples ```bash # Download binary manually (fallback if npm package unavailable) diff --git a/packages/fff-bun/examples/glob-bench.ts b/packages/fff-bun/examples/glob-bench.ts index b8922e40..82fe9244 100644 --- a/packages/fff-bun/examples/glob-bench.ts +++ b/packages/fff-bun/examples/glob-bench.ts @@ -94,7 +94,7 @@ const finder = fffInit.result; // Wait until initial scan done so the first .glob() doesn't see a partial // index. Returns true = completed, false = timed out. -const scanReady = finder.waitForScan(30_000); +const scanReady = finder.waitForScanBlocking(30_000); if (!scanReady.ok || !scanReady.value) { console.error("fff: initial scan did not finish in 30s — exiting"); process.exit(1); diff --git a/packages/fff-bun/src/types.ts b/packages/fff-bun/src/fff-api.ts similarity index 76% rename from packages/fff-bun/src/types.ts rename to packages/fff-bun/src/fff-api.ts index 0cc48967..abff537c 100644 --- a/packages/fff-bun/src/types.ts +++ b/packages/fff-bun/src/fff-api.ts @@ -1,3 +1,25 @@ +// ---------------------------------------------------------------------------- +// GENERATED FILE - DO NOT EDIT. +// Source of truth: 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 */ @@ -35,7 +57,8 @@ export interface InitOptions { /** * 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 background resources spent on awarming up the cache + * ones at the cost of a longer scan time and higher initial memory usage. + * (default: false) */ disableMmapCache?: boolean; /** @@ -395,6 +418,12 @@ export interface GrepOptions { 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; } /** @@ -435,6 +464,8 @@ export interface GrepMatch { 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; } /** @@ -493,4 +524,94 @@ export interface MultiGrepOptions { 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; + + /** Glob-only filtering (no fuzzy matching). */ + glob(pattern: string, options?: GlobOptions): Result; + + /** Fuzzy directory search. */ + directorySearch(query: string, options?: DirSearchOptions): Result; + + /** Fuzzy search over files and directories interleaved by score. */ + mixedSearch(query: string, options?: SearchOptions): Result; + + /** Content search (live grep). */ + grep(query: string, options?: GrepOptions): Result; + + /** Multi-pattern OR content search (Aho-Corasick). */ + multiGrep(options: MultiGrepOptions): Result; + + /** Trigger an async rescan of the indexed directory. */ + scanFiles(): Result; + + /** Whether a scan is currently in progress. */ + isScanning(): boolean; + + /** The root directory being indexed. */ + getBasePath(): Result; + + /** Current scan progress snapshot. */ + getScanProgress(): Result; + + /** + * 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>; + + /** + * 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; + + /** + * 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>; + + /** Restart indexing in a new directory. */ + reindex(newPath: string): Result; + + /** Refresh the git status cache. Returns the number of updated files. */ + refreshGitStatus(): Result; + + /** Record that `selectedFilePath` was chosen for `query`. */ + trackQuery(query: string, selectedFilePath: string): Result; + + /** Get a historical query by offset (0 = most recent). */ + getHistoricalQuery(offset: number): Result; + + /** Health/diagnostics information for this instance. */ + healthCheck(testPath?: string): Result; } diff --git a/packages/fff-bun/src/ffi.ts b/packages/fff-bun/src/ffi.ts index fe3cf0b5..240d7f60 100644 --- a/packages/fff-bun/src/ffi.ts +++ b/packages/fff-bun/src/ffi.ts @@ -23,8 +23,8 @@ import type { ScanProgress, Score, SearchResult, -} from "./types"; -import { createGrepCursor, err } from "./types"; +} from "./fff-api"; +import { createGrepCursor, err } from "./fff-api"; /** Grep mode constants matching the C API (u8). */ const GREP_MODE_PLAIN = 0; @@ -186,10 +186,6 @@ const ffiDefinition = { args: [FFIType.ptr, FFIType.u64], returns: FFIType.ptr, }, - fff_wait_for_watcher: { - args: [FFIType.ptr, FFIType.u64], - returns: FFIType.ptr, - }, fff_restart_index: { args: [FFIType.ptr, FFIType.cstring], returns: FFIType.ptr, @@ -286,7 +282,6 @@ const ffiDefinition = { type FFFLibrary = ReturnType>; -// Library instance (lazy loaded) let lib: FFFLibrary | null = null; /** @@ -938,6 +933,7 @@ const GM_FUZZY_SCORE = 128; // 1-byte const GM_HAS_FUZZY = 130; const GM_IS_BINARY = 131; +const GM_IS_DEFINITION = 132; // struct size: pad to 8-byte alignment → 136 const GM_SIZE_OF = 136; @@ -1015,6 +1011,9 @@ function readGrepMatchStruct(p: number): GrepMatch { if (ctxAfterCount > 0) { match.contextAfter = readCStringArray(read.ptr(pp, GM_CTX_AFTER), ctxAfterCount); } + if (read.u8(pp, GM_IS_DEFINITION) !== 0) { + match.isDefinition = true; + } return match; } @@ -1298,18 +1297,6 @@ export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result< return parseBoolResult(resultPtr); } -/** - * Wait for the background file watcher to be ready. - */ -export function ffiWaitForWatcher( - handle: NativeHandle, - timeoutMs: number, -): Result { - const library = loadLibrary(); - const resultPtr = library.symbols.fff_wait_for_watcher(handle, BigInt(timeoutMs)); - return parseBoolResult(resultPtr); -} - /** * Restart index in new path. */ diff --git a/packages/fff-bun/src/finder.ts b/packages/fff-bun/src/finder.ts index 22f11e5f..4d8c7adf 100644 --- a/packages/fff-bun/src/finder.ts +++ b/packages/fff-bun/src/finder.ts @@ -28,7 +28,6 @@ import { ffiSearchMixed, ffiTrackQuery, ffiWaitForScan, - ffiWaitForWatcher, isAvailable, type NativeHandle, } from "./ffi"; @@ -36,20 +35,21 @@ import { import type { DirSearchOptions, DirSearchResult, + InitOptions as FFFInitOptions, + FileFinderApi, GlobOptions, GrepOptions, GrepResult, HealthCheck, - InitOptions as FFFInitOptions, MixedSearchResult, MultiGrepOptions, Result, ScanProgress, SearchOptions, SearchResult, -} from "./types"; +} from "./fff-api"; -import { err } from "./types"; +import { err } from "./fff-api"; /** * FileFinder - Fast file finder with fuzzy search @@ -69,7 +69,7 @@ import { err } from "./types"; * } * * // Wait for initial scan - * finder.value.waitForScan(5000); + * await finder.value.waitForScan(5000); * * // Search for files * const search = finder.value.search("main.ts"); @@ -83,7 +83,7 @@ import { err } from "./types"; * finder.value.destroy(); * ``` */ -export class FileFinder { +export class FileFinder implements FileFinderApi { private handle: NativeHandle | null; private constructor(handle: NativeHandle) { @@ -353,7 +353,7 @@ export class FileFinder { options?.timeBudgetMs ?? 0, options?.beforeContext ?? 0, options?.afterContext ?? 0, - false, + options?.classifyDefinitions ?? false, ); } @@ -402,7 +402,7 @@ export class FileFinder { options.timeBudgetMs ?? 0, options.beforeContext ?? 0, options.afterContext ?? 0, - false, + options.classifyDefinitions ?? false, ); } @@ -447,6 +447,9 @@ export class FileFinder { /** * Wait for the initial file scan to complete. * + * Non-blocking: polls `isScanning` and yields to the event loop between + * checks, so other async work keeps running while waiting. + * * @param timeoutMs - Maximum time to wait in milliseconds (default: 5000) * @returns true if scan completed, false if timed out * @@ -454,33 +457,67 @@ export class FileFinder { * ```typescript * const finder = FileFinder.create({ basePath: "/path/to/project" }); * if (finder.ok) { - * const completed = finder.value.waitForScan(10000); + * const completed = await finder.value.waitForScan(10000); * if (!completed.ok || !completed.value) { * console.warn("Scan did not complete in time"); * } * } * ``` */ - waitForScan(timeoutMs: number = 5000): Result { + async waitForScan(timeoutMs: number = 5000): Promise> { + 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 { const guard = this.ensureAlive(); if (!guard.ok) return guard; return ffiWaitForScan(guard.value, timeoutMs); } /** - * Wait for the background file watcher to be ready. + * Wait until the index is fully ready: the scan has finished and the warmup + * (content indexing / bigram) phase has completed. * - * The watcher is created after the initial scan, git status, and optional - * warmup phases complete. Useful for tests that need to ensure filesystem - * events will be detected. + * Non-blocking — polls `getScanProgress` and yields to the event loop. * - * @param timeoutMs - Maximum time to wait in milliseconds (default: 10000) - * @returns true if watcher is ready, false if timed out + * @param timeoutMs - Maximum time to wait in milliseconds (default: 5000) + * @returns true if the index became ready, false if timed out */ - waitForWatcher(timeoutMs: number = 10000): Result { + async waitForIndexReady(timeoutMs: number = 5000): Promise> { const guard = this.ensureAlive(); if (!guard.ok) return guard; - return ffiWaitForWatcher(guard.value, timeoutMs); + + 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)); + } } /** diff --git a/packages/fff-bun/src/git-lifecycle.test.ts b/packages/fff-bun/src/git-lifecycle.test.ts index 96f86900..d64e9aac 100644 --- a/packages/fff-bun/src/git-lifecycle.test.ts +++ b/packages/fff-bun/src/git-lifecycle.test.ts @@ -10,8 +10,8 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { FileItem } from "./fff-api"; import { FileFinder } from "./index"; -import type { FileItem } from "./types"; /** * Integration test: full git lifecycle with a real repository. @@ -149,7 +149,7 @@ describe.skipIf(process.platform === "win32")("Git lifecycle integration", () => finder = result.value; // Wait for the initial scan to finish - const scanResult = finder.waitForScan(10_000); + const scanResult = finder.waitForScanBlocking(10_000); expect(scanResult.ok).toBe(true); // Poll getScanProgress until the watcher is ready so that diff --git a/packages/fff-bun/src/index.test.ts b/packages/fff-bun/src/index.test.ts index e86c6b8d..f35061cb 100644 --- a/packages/fff-bun/src/index.test.ts +++ b/packages/fff-bun/src/index.test.ts @@ -102,9 +102,9 @@ describe("FileFinder - Full Lifecycle", () => { } }); - test("waitForScan completes", () => { + test("waitForScanBlocking completes", () => { // Small timeout - scan should be fast or already done - const result = finder.waitForScan(500); + const result = finder.waitForScanBlocking(500); expect(result.ok).toBe(true); }); @@ -204,7 +204,12 @@ describe("FileFinder - Full Lifecycle", () => { const page1 = finder.glob("**/*.ts", { pageSize: 5, pageIndex: 1 }); expect(page0.ok).toBe(true); expect(page1.ok).toBe(true); - if (page0.ok && page1.ok && page0.value.items.length > 1 && page1.value.items.length > 0) { + if ( + page0.ok && + page1.ok && + page0.value.items.length > 1 && + page1.value.items.length > 0 + ) { expect(page1.value.items[0]!.relativePath).toBe(page0.value.items[1]!.relativePath); } }); @@ -400,7 +405,7 @@ describe("FileFinder - Directory Search", () => { if (result.ok) { finder = result.value; } - finder.waitForScan(5000); + finder.waitForScanBlocking(5000); }); afterAll(() => { @@ -479,7 +484,7 @@ describe("FileFinder - Error Handling", () => { describe("Result Type Helpers", () => { test("ok helper creates success result", async () => { - const { ok } = await import("./types"); + const { ok } = await import("./fff-api"); const result = ok(42); expect(result.ok).toBe(true); if (result.ok) { @@ -488,7 +493,7 @@ describe("Result Type Helpers", () => { }); test("err helper creates error result", async () => { - const { err } = await import("./types"); + const { err } = await import("./fff-api"); const result = err("something went wrong"); expect(result.ok).toBe(false); if (!result.ok) { diff --git a/packages/fff-bun/src/index.ts b/packages/fff-bun/src/index.ts index 564eedb7..f483fde1 100644 --- a/packages/fff-bun/src/index.ts +++ b/packages/fff-bun/src/index.ts @@ -1,60 +1,11 @@ -/** - * fff - Fast File Finder - * - * High-performance fuzzy file finder for Bun, 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. - * - * @example - * ```typescript - * import { FileFinder } from "fff"; - * - * // 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 - * 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 "./download"; -export { FileFinder } from "./finder"; - -export { - getLibExtension, - getLibFilename, - getNpmPackageName, - getTriple, -} from "./platform"; - +export { binaryExists, findBinary } from "./download"; export type { DbHealth, DirItem, DirSearchOptions, DirSearchResult, + err, + FileFinderApi, FileItem, GrepCursor, GrepMatch, @@ -67,11 +18,18 @@ export type { MixedItem, MixedSearchResult, MultiGrepOptions, + ok, Result, ScanProgress, Score, SearchOptions, SearchResult, -} from "./types"; -// Result helpers -export { err, ok } from "./types"; +} from "./fff-api"; + +export { FileFinder } from "./finder"; +export { + getLibExtension, + getLibFilename, + getNpmPackageName, + getTriple, +} from "./platform"; diff --git a/packages/fff-bun/test.ts b/packages/fff-bun/test.ts index 1701694f..de29fdb2 100644 --- a/packages/fff-bun/test.ts +++ b/packages/fff-bun/test.ts @@ -126,7 +126,7 @@ async function main() { const finder2 = finder2Result.value; console.log(" Second instance created successfully"); - finder2.waitForScan(5000); + finder2.waitForScanBlocking(5000); const search2 = finder2.fileSearch("Cargo.toml"); if (search2.ok) { console.log( diff --git a/packages/fff-node/README.md b/packages/fff-node/README.md new file mode 100644 index 00000000..49c4a975 --- /dev/null +++ b/packages/fff-node/README.md @@ -0,0 +1,126 @@ +# fff - Fast File Finder + +High-performance fuzzy file finder for Node.js, powered by Rust. Extremely fast live file, content, and directory search with a typo-resistant algorithm. As well as regex, plain-text, multi-occurrence and typo-resistant content search. + +Comes with built-in git status support, frecency access tracking, and a real-time file watcher, content indexing and many more! Designed for LLM agent tools that search through codebases or agentic RAG document search. + +Faster than ripgrep & fzf on any workflow that runs more than once per process. + +## Installation + +```bash +npm install @ff-labs/fff-node +``` + +The correct native binary for your platform is installed automatically via platform-specific packages (e.g. `@ff-labs/fff-bin-darwin-arm64`, `@ff-labs/fff-bin-linux-x64-gnu`) + +### Supported Platforms + +| Platform | Architecture | Package | +| -------- | --------------------- | ----------------------------------- | +| macOS | ARM64 (Apple Silicon) | `@ff-labs/fff-bin-darwin-arm64` | +| macOS | x64 (Intel) | `@ff-labs/fff-bin-darwin-x64` | +| Linux | x64 (glibc) | `@ff-labs/fff-bin-linux-x64-gnu` | +| Linux | ARM64 (glibc) | `@ff-labs/fff-bin-linux-arm64-gnu` | +| Linux | x64 (musl) | `@ff-labs/fff-bin-linux-x64-musl` | +| Linux | ARM64 (musl) | `@ff-labs/fff-bin-linux-arm64-musl` | +| Windows | x64 | `@ff-labs/fff-bin-win32-x64` | +| Windows | ARM64 | `@ff-labs/fff-bin-win32-arm64` | + +If the platform package isn't available, the postinstall script will attempt to download from GitHub releases as a fallback. + +## Quick Start + +Each `FileFinder` instance owns an independent native index. Create one, wait +for the initial scan, then run as many searches as you like. + +```typescript +import { FileFinder } from "@ff-labs/fff-node"; + +// Create an instance bound to a directory +const created = FileFinder.create({ basePath: "/path/to/project" }); +if (!created.ok) throw new Error(created.error); + +const finder = created.value; + +// Wait for the initial scan (async, non-blocking) +await finder.waitForScan(5000); + +// 1. Fuzzy file search (typo resistant) +const files = finder.fileSearch("typescropt.ts", { pageSize: 10 }); +if (files.ok) { + for (const item of files.value.items) { + console.log(item.relativePath, item.gitStatus); + } +} + +// 2. Glob filter — no fuzzy matching, 100% compatible with npm `glob` +const globbed = finder.glob("src/**/*.ts"); +if (globbed.ok) console.log(`${globbed.value.totalMatched} TypeScript files`); + +// 3. Content search (live grep) with pagination +const grep = finder.grep("TODO", { mode: "plain", pageSize: 20 }); +if (grep.ok) { + for (const m of grep.value.items) { + console.log(`${m.relativePath}:${m.lineNumber}: ${m.lineContent}`); + } +} + +// 4. Directory search based on the query (typo resistant) +const dirs = finder.directorySearch("components"); +if (dirs.ok) console.log(dirs.value.items.map((d) => d.relativePath)); + +// Free the resources when you don't need a file picker anymore +finder.destroy(); +``` + +## API Reference + +Verify the latest API in the local interface at [`./src/fff-api.ts`](./src/fff-api.ts). Every field and type is documented. + +### Result Types + +All methods return a `Result` type for explicit error handling: + +```typescript +type Result = { ok: true; value: T } | { ok: false; error: string }; + +const result = finder.fileSearch("foo"); + +if (result.ok) { + // result.value is SearchResult +} else { + // result.error is string error message +} +``` + +This SDK calls a native compiled library for your platform at runtime. This is generally safe — fff is battle-tested and stable, and written in a memory-safe language — but there is a class of errors that can't be caught at the Node.js level. If you hit one, please report an issue! + +## Building from Source + +If prebuilt binaries aren't available for your platform: + +```bash +# Clone the repository +git clone https://github.com/dmtrKovalenko/fff.nvim +cd fff.nvim + +# Build the C library +cargo build --release -p fff-c + +# The binary will be at target/release/libfff_c.{so,dylib,dll} +``` + +## CLI examples + +```bash +# Download binary manually (fallback if npm package unavailable) +npx @ff-labs/fff-node download [tag] + +# Show platform info and binary location +npx @ff-labs/fff-node info +``` + +## License + +MIT diff --git a/packages/fff-node/src/fff-api.ts b/packages/fff-node/src/fff-api.ts new file mode 100644 index 00000000..abff537c --- /dev/null +++ b/packages/fff-node/src/fff-api.ts @@ -0,0 +1,617 @@ +// ---------------------------------------------------------------------------- +// GENERATED FILE - DO NOT EDIT. +// Source of truth: 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 = { ok: true; value: T } | { ok: false; error: string }; + +/** + * Helper to create a successful result + */ +export function ok(value: T): Result { + return { ok: true, value }; +} + +/** + * Helper to create an error result + */ +export function err(error: string): Result { + 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. The no-lock LMDB flags showed no measurable win under + * realistic contention and are now ignored. Kept for source-compat. + */ + 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. + * (default: false) + */ + disableMmapCache?: boolean; + /** + * Disable the content index built after the initial scan. + * Content indexing enables faster content-aware filtering during grep. + * When omitted, follows `disableMmapCache` for backward compatibility. + * (default: follows `disableMmapCache`) + */ + 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 — root is + * rarely the intended target and floods the watcher with churn-prone + * events. Setting this true is opt-in and the caller is responsible for + * the resulting fs-event volume. + */ + enableFsRootScanning?: boolean; + /** + * Allow indexing the user's home directory. Same trade-off as + * `enableFsRootScanning`. + */ + enableHomeDirScanning?: 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; + + /** Glob-only filtering (no fuzzy matching). */ + glob(pattern: string, options?: GlobOptions): Result; + + /** Fuzzy directory search. */ + directorySearch(query: string, options?: DirSearchOptions): Result; + + /** Fuzzy search over files and directories interleaved by score. */ + mixedSearch(query: string, options?: SearchOptions): Result; + + /** Content search (live grep). */ + grep(query: string, options?: GrepOptions): Result; + + /** Multi-pattern OR content search (Aho-Corasick). */ + multiGrep(options: MultiGrepOptions): Result; + + /** Trigger an async rescan of the indexed directory. */ + scanFiles(): Result; + + /** Whether a scan is currently in progress. */ + isScanning(): boolean; + + /** The root directory being indexed. */ + getBasePath(): Result; + + /** Current scan progress snapshot. */ + getScanProgress(): Result; + + /** + * 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>; + + /** + * 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; + + /** + * 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>; + + /** Restart indexing in a new directory. */ + reindex(newPath: string): Result; + + /** Refresh the git status cache. Returns the number of updated files. */ + refreshGitStatus(): Result; + + /** Record that `selectedFilePath` was chosen for `query`. */ + trackQuery(query: string, selectedFilePath: string): Result; + + /** Get a historical query by offset (0 = most recent). */ + getHistoricalQuery(offset: number): Result; + + /** Health/diagnostics information for this instance. */ + healthCheck(testPath?: string): Result; +} diff --git a/packages/fff-node/src/ffi.ts b/packages/fff-node/src/ffi.ts index e1861106..e9ab6d4d 100644 --- a/packages/fff-node/src/ffi.ts +++ b/packages/fff-node/src/ffi.ts @@ -57,8 +57,8 @@ import type { Result, Score, SearchResult, -} from "./types.js"; -import { createGrepCursor, err } from "./types.js"; +} from "./fff-api.js"; +import { createGrepCursor, err } from "./fff-api.js"; const LIBRARY_KEY = "fff_c"; @@ -237,11 +237,7 @@ function readResultEnvelope( paramsValue: unknown[], ): { rawPtr: JsExternal; struct: FffResultRaw } | Result { loadLibrary(); - const { rawPtr, struct: structData } = callRaw( - funcName, - paramsType, - paramsValue, - ); + const { rawPtr, struct: structData } = callRaw(funcName, paramsType, paramsValue); if (structData.success === 0) { const errorStr = readCString(structData.error); @@ -319,8 +315,7 @@ function callJsonResult( if (isNullPointer(handlePtr)) return { ok: true, value: undefined as T }; const jsonStr = readCString(handlePtr); freeString(handlePtr); - if (jsonStr === null || jsonStr === "") - return { ok: true, value: undefined as T }; + if (jsonStr === null || jsonStr === "") return { ok: true, value: undefined as T }; try { return { ok: true, value: snakeToCamel(JSON.parse(jsonStr)) as T }; } catch { @@ -598,7 +593,6 @@ interface FffMixedSearchResultRaw { location_end_col: number; } -// FffGrepMatch (144 bytes) — ordered by alignment: ptrs, u64s, u32s, u16, bools const FFF_GREP_MATCH_STRUCT = { relative_path: DataType.External, file_name: DataType.External, @@ -618,7 +612,7 @@ const FFF_GREP_MATCH_STRUCT = { match_ranges_count: DataType.U32, context_before_count: DataType.U32, context_after_count: DataType.U32, - fuzzy_score: DataType.U32, // actually u16 in C, but ffi-rs doesn't have U16 — reads as u32 with padding + fuzzy_score: DataType.U32, // actually u16 in C, but ffi-rs doesn't so we read it as u32 with padding has_fuzzy_score: DataType.U8, is_binary: DataType.U8, is_definition: DataType.U8, @@ -839,16 +833,10 @@ function readGrepMatchFromRaw(raw: FffGrepMatchRaw): GrepMatch { match.fuzzyScore = raw.fuzzy_score; } if (raw.context_before_count > 0) { - match.contextBefore = readCStringArray( - raw.context_before, - raw.context_before_count, - ); + match.contextBefore = readCStringArray(raw.context_before, raw.context_before_count); } if (raw.context_after_count > 0) { - match.contextAfter = readCStringArray( - raw.context_after, - raw.context_after_count, - ); + match.contextAfter = readCStringArray(raw.context_after, raw.context_after_count); } if (raw.is_definition !== 0) { match.isDefinition = true; @@ -917,8 +905,7 @@ function parseGrepResult(rawPtr: JsExternal): Result { totalFilesSearched: gr.total_files_searched, totalFiles: gr.total_files, filteredFileCount: gr.filtered_file_count, - nextCursor: - gr.next_file_offset > 0 ? createGrepCursor(gr.next_file_offset) : null, + nextCursor: gr.next_file_offset > 0 ? createGrepCursor(gr.next_file_offset) : null, }; if (regexFallbackError) { grepResult.regexFallbackError = regexFallbackError; @@ -1270,14 +1257,7 @@ export function ffiGlob( DataType.U32, // page_index DataType.U32, // page_size ], - paramsValue: [ - handle, - pattern, - currentFile, - maxThreads, - pageIndex, - pageSize, - ], + paramsValue: [handle, pattern, currentFile, maxThreads, pageIndex, pageSize], freeResultMemory: false, }) as JsExternal; @@ -1309,14 +1289,7 @@ export function ffiSearchDirectories( DataType.U32, // page_index DataType.U32, // page_size ], - paramsValue: [ - handle, - query, - currentFile ?? "", - maxThreads, - pageIndex, - pageSize, - ], + paramsValue: [handle, query, currentFile ?? "", maxThreads, pageIndex, pageSize], freeResultMemory: false, }) as JsExternal; @@ -1514,25 +1487,28 @@ export function ffiGetBasePath(handle: NativeHandle): Result { const FFF_SCAN_PROGRESS_STRUCT = { scanned_files_count: DataType.U64, is_scanning: DataType.U8, + is_watcher_ready: DataType.U8, + is_warmup_complete: DataType.U8, }; interface FffScanProgressRaw { scanned_files_count: number; is_scanning: number; + is_watcher_ready: number; + is_warmup_complete: number; } /** * Get scan progress. */ -export function ffiGetScanProgress( - handle: NativeHandle, -): Result<{ scannedFilesCount: number; isScanning: boolean }> { +export function ffiGetScanProgress(handle: NativeHandle): Result<{ + scannedFilesCount: number; + isScanning: boolean; + isWatcherReady: boolean; + isWarmupComplete: boolean; +}> { loadLibrary(); - const res = readResultEnvelope( - "fff_get_scan_progress", - [DataType.External], - [handle], - ); + const res = readResultEnvelope("fff_get_scan_progress", [DataType.External], [handle]); if ("ok" in res) return res; const handlePtr = res.struct.handle; @@ -1548,6 +1524,8 @@ export function ffiGetScanProgress( const result = { scannedFilesCount: Number(sp.scanned_files_count), isScanning: sp.is_scanning !== 0, + isWatcherReady: sp.is_watcher_ready !== 0, + isWarmupComplete: sp.is_warmup_complete !== 0, }; // Free native scan progress @@ -1565,10 +1543,7 @@ export function ffiGetScanProgress( /** * Wait for a tree scan to complete. */ -export function ffiWaitForScan( - handle: NativeHandle, - timeoutMs: number, -): Result { +export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result { return callBoolResult( "fff_wait_for_scan", [DataType.External, DataType.U64], @@ -1579,10 +1554,7 @@ export function ffiWaitForScan( /** * Restart index in new path. */ -export function ffiRestartIndex( - handle: NativeHandle, - newPath: string, -): Result { +export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result { return callVoidResult( "fff_restart_index", [DataType.External, DataType.String], diff --git a/packages/fff-node/src/finder.ts b/packages/fff-node/src/finder.ts index e8622bcc..02957a33 100644 --- a/packages/fff-node/src/finder.ts +++ b/packages/fff-node/src/finder.ts @@ -27,6 +27,7 @@ import { ffiSearchDirectories, ffiSearchMixed, ffiTrackQuery, + ffiWaitForScan, isAvailable, type NativeHandle, } from "./ffi.js"; @@ -34,6 +35,7 @@ import { import type { DirSearchOptions, DirSearchResult, + FileFinderApi, GlobOptions, GrepOptions, GrepResult, @@ -45,9 +47,9 @@ import type { ScanProgress, SearchOptions, SearchResult, -} from "./types.js"; +} from "./fff-api.js"; -import { err } from "./types.js"; +import { err } from "./fff-api.js"; /** * FileFinder - Fast file finder with fuzzy search @@ -68,7 +70,7 @@ import { err } from "./types.js"; * } * * // Wait for initial scan - * finder.value.waitForScan(5000); + * await finder.value.waitForScan(5000); * * // Search for files * const search = finder.value.search("main.ts"); @@ -82,7 +84,7 @@ import { err } from "./types.js"; * finder.value.destroy(); * ``` */ -export class FileFinder { +export class FileFinder implements FileFinderApi { private handle: NativeHandle | null; private constructor(handle: NativeHandle) { @@ -456,8 +458,7 @@ export class FileFinder { /** * Wait for the initial file scan to complete. - * - * Non-blocking — polls `isScanning` and yields to the event loop between checks. + * 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 @@ -487,6 +488,48 @@ export class FileFinder { 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 { + 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> { + 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. * diff --git a/packages/fff-node/src/index.ts b/packages/fff-node/src/index.ts index 7b5f890a..78cc9242 100644 --- a/packages/fff-node/src/index.ts +++ b/packages/fff-node/src/index.ts @@ -22,7 +22,7 @@ * const finder = result.value; * * // Wait for initial scan - * finder.waitForScan(5000); + * await finder.waitForScan(5000); * * // Search for files * const search = finder.fileSearch("main.ts"); @@ -44,19 +44,12 @@ export { findBinary, } from "./binary.js"; export { closeLibrary } from "./ffi.js"; -export { FileFinder } from "./finder.js"; -export { - getLibExtension, - getLibFilename, - getNpmPackageName, - getTriple, -} from "./platform.js"; - export type { DbHealth, DirItem, DirSearchOptions, DirSearchResult, + FileFinderApi, FileItem, GrepCursor, GrepMatch, @@ -74,6 +67,13 @@ export type { Score, SearchOptions, SearchResult, -} from "./types.js"; +} from "./fff-api.js"; // Result helpers -export { err, ok } from "./types.js"; +export { err, ok } from "./fff-api.js"; +export { FileFinder } from "./finder.js"; +export { + getLibExtension, + getLibFilename, + getNpmPackageName, + getTriple, +} from "./platform.js"; diff --git a/packages/fff-node/src/platform.ts b/packages/fff-node/src/platform.ts index 7813eae9..5b939191 100644 --- a/packages/fff-node/src/platform.ts +++ b/packages/fff-node/src/platform.ts @@ -37,11 +37,11 @@ function detectLinuxLibc(): string { timeout: 5000, }); } catch (e: unknown) { - // Alpine/musl: `ldd --version` exits with code 1 but still prints - // "musl libc ..." — execSync surfaces that on the error object. const err = e as { stdout?: string | Buffer; stderr?: string | Buffer }; output = String(err?.stdout ?? "") + String(err?.stderr ?? ""); } + + // ldd on musl can produce stdout with musl either with exit code 1 or 0 if (output.toLowerCase().includes("musl")) { return "unknown-linux-musl"; } diff --git a/packages/fff-node/test/e2e.mjs b/packages/fff-node/test/e2e.mjs index 28c08091..c24af737 100644 --- a/packages/fff-node/test/e2e.mjs +++ b/packages/fff-node/test/e2e.mjs @@ -1,22 +1,8 @@ -/** - * End-to-end tests for the fff-node package. - * - * Indexes the fff.nvim repository itself so the test suite is fully - * self-contained — no external projects required. - * - * Requires: - * - A built Rust library (cargo build --release -p fff-c) - * - A compiled TS dist (cd packages/fff-node && npx tsc) - * - * Run: - * node --test test/e2e.mjs - */ - import { after, before, describe, it } from "node:test"; import { strict as assert } from "node:assert"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { FileFinder, closeLibrary } from "../dist/src/index.js"; +import { FileFinder } from "../dist/src/index.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, "..", "..", ".."); @@ -40,10 +26,9 @@ describe("fff-node", { concurrency: 1 }, () => { }); after(() => { - if (finder && !finder.isDestroyed) finder.destroy(); - // Skip closeLibrary() — the OS handles cleanup on process exit. - // Calling ffi-rs close() during test teardown can cause native crashes - // on some platforms (e.g. Windows DLL unload, Linux dlclose). + if (finder && !finder.isDestroyed) { + finder.destroy(); + } }); it("isAvailable returns true when the native library is loadable", () => { @@ -136,7 +121,10 @@ describe("fff-node", { concurrency: 1 }, () => { assert.ok(r.ok, `glob failed: ${!r.ok ? r.error : ""}`); assert.ok(r.value.items.length > 0, "expected at least one .rs file"); for (const item of r.value.items) { - assert.ok(item.relativePath.endsWith(".rs"), `unexpected file: ${item.relativePath}`); + assert.ok( + item.relativePath.endsWith(".rs"), + `unexpected file: ${item.relativePath}`, + ); } }); @@ -163,14 +151,32 @@ describe("fff-node", { concurrency: 1 }, () => { // Constrain to .rs files so the assertion doesn't depend on result ordering // or content-indexing timing for other file types. const rustResults = finder.grep("*.rs FffResult", { mode: "plain" }); - assert.ok(rustResults.ok, `grep failed: ${!rustResults.ok ? rustResults.error : ""}`); - assert.ok(rustResults.value.items.length > 0, "expected at least one .rs match"); - assert.ok(rustResults.value.items.some((m) => m.relativePath.endsWith(".rs"))); - - const cResults = finder.grep("!**/*.{js,ts,rs} FffResult", { mode: "plain" }); - assert.ok(cResults.ok, `grep failed: ${!cResults.ok ? cResults.error : ""}`); - assert.ok(cResults.value.items.length > 0, "expected at least one non-js/ts/rs match"); - assert.ok(cResults.value.items.some((m) => m.relativePath.endsWith(".h"))); + assert.ok( + rustResults.ok, + `grep failed: ${!rustResults.ok ? rustResults.error : ""}`, + ); + assert.ok( + rustResults.value.items.length > 0, + "expected at least one .rs match", + ); + assert.ok( + rustResults.value.items.some((m) => m.relativePath.endsWith(".rs")), + ); + + const cResults = finder.grep("!**/*.{js,ts,rs} FffResult", { + mode: "plain", + }); + assert.ok( + cResults.ok, + `grep failed: ${!cResults.ok ? cResults.error : ""}`, + ); + assert.ok( + cResults.value.items.length > 0, + "expected at least one non-js/ts/rs match", + ); + assert.ok( + cResults.value.items.some((m) => m.relativePath.endsWith(".h")), + ); }); it("match items contain all required fields", () => { @@ -200,8 +206,14 @@ describe("fff-node", { concurrency: 1 }, () => { it("respects pageSize", () => { // Cap to one match per file so pageSize bounds the total deterministically. - const unbounded = finder.grep("fn", { mode: "plain", maxMatchesPerFile: 1 }); - assert.ok(unbounded.ok, `grep failed: ${!unbounded.ok ? unbounded.error : ""}`); + const unbounded = finder.grep("fn", { + mode: "plain", + maxMatchesPerFile: 1, + }); + assert.ok( + unbounded.ok, + `grep failed: ${!unbounded.ok ? unbounded.error : ""}`, + ); assert.ok(unbounded.value.items.length > 2); const limited = finder.grep("fn", { @@ -240,7 +252,8 @@ describe("fff-node", { concurrency: 1 }, () => { assert.ok(r.ok, `grep with context failed: ${!r.ok ? r.error : ""}`); const match = r.value.items.find( - (m) => normalizePath(m.relativePath) === "packages/fff-node/test/e2e.mjs", + (m) => + normalizePath(m.relativePath) === "packages/fff-node/test/e2e.mjs", ); assert.ok( match, @@ -248,9 +261,7 @@ describe("fff-node", { concurrency: 1 }, () => { .map((m) => normalizePath(m.relativePath)) .join(", ")}`, ); - assert.deepEqual(match.contextBefore, [ - " const r = finder.grep(", - ]); + assert.deepEqual(match.contextBefore, [" const r = finder.grep("]); assert.deepEqual(match.contextAfter, [" {"]); }); }); diff --git a/packages/fff-node/src/types.ts b/packages/shared/fff-api.ts similarity index 80% rename from packages/fff-node/src/types.ts rename to packages/shared/fff-api.ts index c96f1c4e..f4420148 100644 --- a/packages/fff-node/src/types.ts +++ b/packages/shared/fff-api.ts @@ -1,3 +1,19 @@ +/** + * 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 */ @@ -273,6 +289,10 @@ export interface ScanProgress { 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; } /** @@ -390,14 +410,14 @@ export interface GrepOptions { 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; - /** Maximum matches to return in this page across all files (default: 50) */ - pageSize?: number; } /** @@ -496,11 +516,96 @@ export interface MultiGrepOptions { 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; - /** Maximum matches to return in this page across all files (default: 50) */ - pageSize?: number; +} + +/** + * 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; + + /** Glob-only filtering (no fuzzy matching). */ + glob(pattern: string, options?: GlobOptions): Result; + + /** Fuzzy directory search. */ + directorySearch(query: string, options?: DirSearchOptions): Result; + + /** Fuzzy search over files and directories interleaved by score. */ + mixedSearch(query: string, options?: SearchOptions): Result; + + /** Content search (live grep). */ + grep(query: string, options?: GrepOptions): Result; + + /** Multi-pattern OR content search (Aho-Corasick). */ + multiGrep(options: MultiGrepOptions): Result; + + /** Trigger an async rescan of the indexed directory. */ + scanFiles(): Result; + + /** Whether a scan is currently in progress. */ + isScanning(): boolean; + + /** The root directory being indexed. */ + getBasePath(): Result; + + /** Current scan progress snapshot. */ + getScanProgress(): Result; + + /** + * 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>; + + /** + * 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; + + /** + * 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>; + + /** Restart indexing in a new directory. */ + reindex(newPath: string): Result; + + /** Refresh the git status cache. Returns the number of updated files. */ + refreshGitStatus(): Result; + + /** Record that `selectedFilePath` was chosen for `query`. */ + trackQuery(query: string, selectedFilePath: string): Result; + + /** Get a historical query by offset (0 = most recent). */ + getHistoricalQuery(offset: number): Result; + + /** Health/diagnostics information for this instance. */ + healthCheck(testPath?: string): Result; }