From cd444eb0ea6d0211b10ab189797506737a82a67b Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 13 Mar 2026 13:13:43 -0600 Subject: [PATCH] merge: integrate native fd module (#231) --- native/Cargo.lock | 1 + native/crates/engine/src/fd.rs | 494 ++++++++++++++++++++++ native/crates/engine/src/lib.rs | 3 +- packages/native/package.json | 7 +- packages/native/src/__tests__/fd.test.mjs | 164 +++++++ packages/native/src/fd/index.ts | 35 ++ packages/native/src/fd/types.ts | 31 ++ packages/native/src/index.ts | 8 + packages/native/src/native.ts | 1 + 9 files changed, 741 insertions(+), 3 deletions(-) create mode 100644 native/crates/engine/src/fd.rs create mode 100644 packages/native/src/__tests__/fd.test.mjs create mode 100644 packages/native/src/fd/index.ts create mode 100644 packages/native/src/fd/types.ts diff --git a/native/Cargo.lock b/native/Cargo.lock index 164bafec7..befccef43 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -157,6 +157,7 @@ name = "gsd-engine" version = "0.1.0" dependencies = [ "gsd-grep", + "ignore", "napi", "napi-build", "napi-derive", diff --git a/native/crates/engine/src/fd.rs b/native/crates/engine/src/fd.rs new file mode 100644 index 000000000..d792d1a0d --- /dev/null +++ b/native/crates/engine/src/fd.rs @@ -0,0 +1,494 @@ +//! Fuzzy file path discovery for autocomplete and @-mention resolution. +//! +//! Searches for files and directories whose paths match a query string via +//! subsequence scoring. Uses the `ignore` crate for directory walking +//! (respects `.gitignore`, hidden files, etc.). + +use std::path::Path; + +use ignore::WalkBuilder; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +// ═══════════════════════════════════════════════════════════════════════════ +// Public types +// ═══════════════════════════════════════════════════════════════════════════ + +/// Options for fuzzy file path search. +#[napi(object)] +pub struct FuzzyFindOptions { + /// Fuzzy query to match against file paths (case-insensitive). + pub query: String, + /// Directory to search. + pub path: String, + /// Include hidden files (default: false). + pub hidden: Option, + /// Respect .gitignore (default: true). + pub gitignore: Option, + /// Maximum number of matches to return (default: 100). + #[napi(js_name = "maxResults")] + pub max_results: Option, +} + +/// A single match in fuzzy find results. +#[napi(object)] +pub struct FuzzyFindMatch { + /// Relative path from the search root (uses `/` separators). + pub path: String, + /// Whether this entry is a directory. + #[napi(js_name = "isDirectory")] + pub is_directory: bool, + /// Match quality score (higher is better). + pub score: u32, +} + +/// Result of fuzzy file path search. +#[napi(object)] +pub struct FuzzyFindResult { + /// Matched entries (up to `maxResults`). + pub matches: Vec, + /// Total number of matches found (may exceed `matches.len()`). + #[napi(js_name = "totalMatches")] + pub total_matches: u32, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Path utilities +// ═══════════════════════════════════════════════════════════════════════════ + +/// Resolve a search path string to a canonical `PathBuf` (must be a directory). +fn resolve_search_path(path: &str) -> Result { + let candidate = std::path::PathBuf::from(path); + let root = if candidate.is_absolute() { + candidate + } else { + let cwd = std::env::current_dir() + .map_err(|err| Error::from_reason(format!("Failed to resolve cwd: {err}")))?; + cwd.join(candidate) + }; + let metadata = std::fs::metadata(&root) + .map_err(|err| Error::from_reason(format!("Path not found: {err}")))?; + if !metadata.is_dir() { + return Err(Error::from_reason( + "Search path must be a directory".to_string(), + )); + } + Ok(std::fs::canonicalize(&root).unwrap_or(root)) +} + +/// Check if a path component matches a target string. +fn contains_component(path: &Path, target: &str) -> bool { + path.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|value| value == target) + }) +} + +/// Skip `.git` directories and `node_modules`. +fn should_skip_path(path: &Path) -> bool { + contains_component(path, ".git") || contains_component(path, "node_modules") +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Scoring +// ═══════════════════════════════════════════════════════════════════════════ + +/// Strips separators, whitespace, and punctuation for normalized fuzzy comparison. +fn normalize_fuzzy_text(value: &str) -> String { + value + .chars() + .filter(|ch| !ch.is_whitespace() && !matches!(ch, '/' | '\\' | '.' | '_' | '-')) + .flat_map(|ch| ch.to_lowercase()) + .collect() +} + +/// Scores a query as a subsequence of `target`. Returns 0 if not a subsequence. +fn fuzzy_subsequence_score(query_chars: &[char], target: &str) -> u32 { + if query_chars.is_empty() { + return 1; + } + let mut query_index = 0usize; + let mut gaps = 0u32; + let mut last_match_index: Option = None; + for (target_index, target_ch) in target.chars().enumerate() { + if query_index >= query_chars.len() { + break; + } + if query_chars[query_index] == target_ch { + if let Some(last_index) = last_match_index { + if target_index > last_index + 1 { + gaps = gaps.saturating_add(1); + } + } + last_match_index = Some(target_index); + query_index += 1; + } + } + if query_index != query_chars.len() { + return 0; + } + let gap_penalty = gaps.saturating_mul(5); + 40u32.saturating_sub(gap_penalty).max(1) +} + +/// Composite path scoring: exact > starts-with > contains > fuzzy subsequence. +fn score_fuzzy_path( + path: &str, + is_directory: bool, + query_lower: &str, + normalized_query: &str, + query_chars: &[char], +) -> u32 { + if query_lower.is_empty() { + return if is_directory { 11 } else { 1 }; + } + + let file_name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path); + let lower_file_name = file_name.to_lowercase(); + + let mut score = if lower_file_name == query_lower { + 120 + } else if lower_file_name.starts_with(query_lower) { + 100 + } else if lower_file_name.contains(query_lower) { + 80 + } else { + let lower_path = path.to_lowercase(); + if lower_path.contains(query_lower) { + 60 + } else { + let normalized_file_name = normalize_fuzzy_text(file_name); + let file_name_fuzzy = fuzzy_subsequence_score(query_chars, &normalized_file_name); + if file_name_fuzzy > 0 { + 50 + file_name_fuzzy + } else { + let normalized_path = normalize_fuzzy_text(path); + let path_fuzzy = if normalized_path == normalized_query { + 40 + } else { + fuzzy_subsequence_score(query_chars, &normalized_path) + }; + if path_fuzzy > 0 { + 30 + path_fuzzy + } else { + 0 + } + } + } + }; + + if is_directory && score > 0 { + score += 10; + } + + score +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Directory walking +// ═══════════════════════════════════════════════════════════════════════════ + +/// File type classification for discovered entries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EntryType { + File, + Dir, + Symlink, +} + +/// A filesystem entry discovered during walking. +struct WalkEntry { + /// Relative path from root (forward slashes). + path: String, + /// Entry type. + entry_type: EntryType, +} + +/// Walk a directory tree collecting entries. +fn walk_directory( + root: &Path, + include_hidden: bool, + respect_gitignore: bool, +) -> Vec { + let mut builder = WalkBuilder::new(root); + builder + .hidden(!include_hidden) + .follow_links(false) + .sort_by_file_path(|a, b| a.cmp(b)); + + if respect_gitignore { + builder + .git_ignore(true) + .git_exclude(true) + .git_global(true) + .ignore(true) + .parents(true); + } else { + builder + .git_ignore(false) + .git_exclude(false) + .git_global(false) + .ignore(false) + .parents(false); + } + + let mut entries = Vec::new(); + for entry in builder.build() { + let Ok(entry) = entry else { continue }; + let path = entry.path(); + + if should_skip_path(path) { + continue; + } + + let relative = path.strip_prefix(root).unwrap_or(path); + let relative_str = relative.to_string_lossy(); + if relative_str.is_empty() { + continue; + } + + // Normalize to forward slashes on all platforms. + let relative_str = if cfg!(windows) && relative_str.contains('\\') { + relative_str.replace('\\', "/") + } else { + relative_str.into_owned() + }; + + let Some(metadata) = std::fs::symlink_metadata(path).ok() else { + continue; + }; + let file_type = metadata.file_type(); + let entry_type = if file_type.is_symlink() { + EntryType::Symlink + } else if file_type.is_dir() { + EntryType::Dir + } else { + EntryType::File + }; + + entries.push(WalkEntry { + path: relative_str, + entry_type, + }); + } + entries +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Execution +// ═══════════════════════════════════════════════════════════════════════════ + +/// Saturating cast from u64 to u32. +fn clamp_u32(value: u64) -> u32 { + value.min(u32::MAX as u64) as u32 +} + +/// Fuzzy file path search for autocomplete and @-mention resolution. +/// +/// Searches for files and directories whose paths match the query string. +/// Results are sorted by match quality (higher score = better match). +#[napi(js_name = "fuzzyFind")] +pub fn fuzzy_find(options: FuzzyFindOptions) -> Result { + let root = resolve_search_path(&options.path)?; + let include_hidden = options.hidden.unwrap_or(false); + let respect_gitignore = options.gitignore.unwrap_or(true); + let max_results = options.max_results.unwrap_or(100) as usize; + + if max_results == 0 { + return Ok(FuzzyFindResult { + matches: Vec::new(), + total_matches: 0, + }); + } + + let query_lower = options.query.trim().to_lowercase(); + let normalized_query = normalize_fuzzy_text(&query_lower); + let query_chars: Vec = normalized_query.chars().collect(); + + if !query_lower.is_empty() && normalized_query.is_empty() { + return Ok(FuzzyFindResult { + matches: Vec::new(), + total_matches: 0, + }); + } + + let entries = walk_directory(&root, include_hidden, respect_gitignore); + + let mut scored: Vec = Vec::with_capacity(entries.len().min(256)); + for entry in entries { + if entry.entry_type == EntryType::Symlink { + continue; + } + + let is_directory = entry.entry_type == EntryType::Dir; + let score = score_fuzzy_path( + &entry.path, + is_directory, + &query_lower, + &normalized_query, + &query_chars, + ); + if score == 0 { + continue; + } + + let mut path = entry.path; + if is_directory { + path.push('/'); + } + scored.push(FuzzyFindMatch { + path, + is_directory, + score, + }); + } + + scored.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.path.cmp(&b.path))); + let total_matches = clamp_u32(scored.len() as u64); + let matches = scored.into_iter().take(max_results).collect(); + + Ok(FuzzyFindResult { + matches, + total_matches, + }) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_fuzzy_text() { + assert_eq!(normalize_fuzzy_text("foo/bar.ts"), "foobarts"); + assert_eq!(normalize_fuzzy_text("my_file-name.rs"), "myfilenamers"); + assert_eq!(normalize_fuzzy_text("MyFile"), "myfile"); + assert_eq!(normalize_fuzzy_text(""), ""); + } + + #[test] + fn test_fuzzy_subsequence_score_exact() { + let query: Vec = "abc".chars().collect(); + let score = fuzzy_subsequence_score(&query, "abc"); + assert_eq!(score, 40); + } + + #[test] + fn test_fuzzy_subsequence_score_with_gaps() { + let query: Vec = "ac".chars().collect(); + let score = fuzzy_subsequence_score(&query, "abc"); + assert_eq!(score, 35); + } + + #[test] + fn test_fuzzy_subsequence_score_no_match() { + let query: Vec = "xyz".chars().collect(); + let score = fuzzy_subsequence_score(&query, "abc"); + assert_eq!(score, 0); + } + + #[test] + fn test_fuzzy_subsequence_score_empty_query() { + let query: Vec = Vec::new(); + let score = fuzzy_subsequence_score(&query, "abc"); + assert_eq!(score, 1); + } + + #[test] + fn test_score_fuzzy_path_exact_filename() { + let score = score_fuzzy_path( + "src/main.rs", + false, + "main.rs", + "mainrs", + &"mainrs".chars().collect::>(), + ); + assert_eq!(score, 120); + } + + #[test] + fn test_score_fuzzy_path_starts_with() { + let score = score_fuzzy_path( + "src/main.rs", + false, + "main", + "main", + &"main".chars().collect::>(), + ); + assert_eq!(score, 100); + } + + #[test] + fn test_score_fuzzy_path_contains() { + let score = score_fuzzy_path( + "src/my_main.rs", + false, + "main", + "main", + &"main".chars().collect::>(), + ); + assert_eq!(score, 80); + } + + #[test] + fn test_score_fuzzy_path_directory_bonus() { + let file_score = score_fuzzy_path( + "src/main.rs", + false, + "main.rs", + "mainrs", + &"mainrs".chars().collect::>(), + ); + let dir_score = score_fuzzy_path( + "src/main.rs", + true, + "main.rs", + "mainrs", + &"mainrs".chars().collect::>(), + ); + assert_eq!(dir_score, file_score + 10); + } + + #[test] + fn test_score_fuzzy_path_empty_query() { + let file_score = score_fuzzy_path("src/main.rs", false, "", "", &[]); + let dir_score = score_fuzzy_path("src/", true, "", "", &[]); + assert_eq!(file_score, 1); + assert_eq!(dir_score, 11); + } + + #[test] + fn test_score_fuzzy_path_no_match() { + let score = score_fuzzy_path( + "src/main.rs", + false, + "xyz", + "xyz", + &"xyz".chars().collect::>(), + ); + assert_eq!(score, 0); + } + + #[test] + fn test_walk_directory_real_fs() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let entries = walk_directory(&root, false, true); + let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect(); + assert!( + paths.iter().any(|p| p.contains("fd.rs")), + "Should find fd.rs in {paths:?}" + ); + assert!( + paths.iter().any(|p| p.contains("lib.rs")), + "Should find lib.rs in {paths:?}" + ); + } +} diff --git a/native/crates/engine/src/lib.rs b/native/crates/engine/src/lib.rs index fb7d06c62..c1a5667f9 100644 --- a/native/crates/engine/src/lib.rs +++ b/native/crates/engine/src/lib.rs @@ -8,11 +8,12 @@ #![allow(clippy::needless_pass_by_value)] +mod ast; mod clipboard; +mod fd; mod fs_cache; mod glob; mod glob_util; -mod ast; mod grep; mod highlight; mod html; diff --git a/packages/native/package.json b/packages/native/package.json index 2a15c6f60..e4acd0b59 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -8,7 +8,7 @@ "scripts": { "build:native": "node ../../native/scripts/build.js", "build:native:dev": "node ../../native/scripts/build.js --dev", - "test": "node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs src/__tests__/clipboard.test.mjs src/__tests__/highlight.test.mjs src/__tests__/html.test.mjs src/__tests__/text.test.mjs" + "test": "node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs src/__tests__/clipboard.test.mjs src/__tests__/highlight.test.mjs src/__tests__/html.test.mjs src/__tests__/text.test.mjs src/__tests__/fd.test.mjs" }, "exports": { ".": { @@ -19,7 +19,6 @@ "types": "./src/grep/index.ts", "import": "./src/grep/index.ts" }, -<<<<<<< HEAD "./ps": { "types": "./src/ps/index.ts", "import": "./src/ps/index.ts" @@ -43,6 +42,10 @@ "./text": { "types": "./src/text/index.ts", "import": "./src/text/index.ts" + }, + "./fd": { + "types": "./src/fd/index.ts", + "import": "./src/fd/index.ts" } }, "files": [ diff --git a/packages/native/src/__tests__/fd.test.mjs b/packages/native/src/__tests__/fd.test.mjs new file mode 100644 index 000000000..4a478fad8 --- /dev/null +++ b/packages/native/src/__tests__/fd.test.mjs @@ -0,0 +1,164 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; +import * as os from "node:os"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +// Load the native addon directly +const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); +const platformTag = `${process.platform}-${process.arch}`; +const candidates = [ + path.join(addonDir, `gsd_engine.${platformTag}.node`), + path.join(addonDir, "gsd_engine.dev.node"), +]; + +let native; +for (const candidate of candidates) { + try { + native = require(candidate); + break; + } catch { + // try next + } +} + +if (!native) { + console.error("Native addon not found. Run `npm run build:native -w @gsd/native` first."); + process.exit(1); +} + +describe("native fd: fuzzyFind()", () => { + test("finds files matching a query", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "main.rs"), "fn main() {}"); + fs.writeFileSync(path.join(tmpDir, "lib.rs"), "pub mod lib;"); + fs.writeFileSync(path.join(tmpDir, "utils.ts"), "export {}"); + fs.mkdirSync(path.join(tmpDir, "src")); + fs.writeFileSync(path.join(tmpDir, "src", "helper.rs"), "fn helper() {}"); + + const result = native.fuzzyFind({ query: "main", path: tmpDir }); + + assert.ok(result.matches.length > 0, "Should find at least one match"); + assert.equal(result.matches[0].path, "main.rs"); + assert.equal(result.matches[0].isDirectory, false); + assert.ok(result.matches[0].score > 0); + }); + + test("returns empty results for non-matching query", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "hello.txt"), "hello"); + + const result = native.fuzzyFind({ + query: "zzzznotexist", + path: tmpDir, + }); + + assert.equal(result.matches.length, 0); + assert.equal(result.totalMatches, 0); + }); + + test("respects maxResults limit", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + for (let i = 0; i < 10; i++) { + fs.writeFileSync(path.join(tmpDir, `file${i}.txt`), "content"); + } + + const result = native.fuzzyFind({ + query: "file", + path: tmpDir, + maxResults: 3, + }); + + assert.equal(result.matches.length, 3); + assert.ok(result.totalMatches >= 3); + }); + + test("directories have trailing slash and bonus score", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.mkdirSync(path.join(tmpDir, "models")); + fs.writeFileSync(path.join(tmpDir, "models.ts"), "export {}"); + + const result = native.fuzzyFind({ query: "models", path: tmpDir }); + + const dirMatch = result.matches.find((m) => m.isDirectory); + const fileMatch = result.matches.find((m) => !m.isDirectory); + + assert.ok(dirMatch, "Should find a directory match"); + assert.ok(fileMatch, "Should find a file match"); + assert.ok(dirMatch.path.endsWith("/"), "Directory should have trailing slash"); + assert.ok(dirMatch.score > fileMatch.score, "Directory should score higher"); + }); + + test("empty query returns all entries", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "a.txt"), "a"); + fs.writeFileSync(path.join(tmpDir, "b.txt"), "b"); + fs.writeFileSync(path.join(tmpDir, "c.txt"), "c"); + + const result = native.fuzzyFind({ query: "", path: tmpDir }); + + assert.equal(result.matches.length, 3); + }); + + test("errors on non-existent path", () => { + assert.throws( + () => native.fuzzyFind({ query: "test", path: "/nonexistent/path" }), + { message: /Path not found/ }, + ); + }); + + test("fuzzy subsequence matching works", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "MyComponentFile.tsx"), "export {}"); + fs.writeFileSync(path.join(tmpDir, "other.txt"), "other"); + + // "mcf" should fuzzy-match "MyComponentFile" via subsequence + const result = native.fuzzyFind({ query: "mcf", path: tmpDir }); + + assert.ok(result.matches.length > 0, "Fuzzy subsequence should match"); + assert.ok( + result.matches.some((m) => m.path.includes("MyComponentFile")), + "Should find MyComponentFile via fuzzy match", + ); + }); + + test("results are sorted by score descending", (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-fd-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "main.ts"), ""); + fs.writeFileSync(path.join(tmpDir, "my_main.ts"), ""); + fs.mkdirSync(path.join(tmpDir, "src")); + fs.writeFileSync(path.join(tmpDir, "src", "main.rs"), ""); + + const result = native.fuzzyFind({ + query: "main", + path: tmpDir, + maxResults: 100, + }); + + for (let i = 1; i < result.matches.length; i++) { + assert.ok( + result.matches[i - 1].score >= result.matches[i].score, + `Match ${i - 1} (score ${result.matches[i - 1].score}) should be >= match ${i} (score ${result.matches[i].score})`, + ); + } + }); +}); diff --git a/packages/native/src/fd/index.ts b/packages/native/src/fd/index.ts new file mode 100644 index 000000000..3dc413922 --- /dev/null +++ b/packages/native/src/fd/index.ts @@ -0,0 +1,35 @@ +/** + * Native fuzzy file path discovery using N-API. + * + * High-performance fuzzy file search for autocomplete and @-mention resolution. + * Backed by Rust's `ignore` crate for directory walking with subsequence scoring. + */ + +import { native } from "../native.js"; +import type { + FuzzyFindMatch, + FuzzyFindOptions, + FuzzyFindResult, +} from "./types.js"; + +export type { FuzzyFindMatch, FuzzyFindOptions, FuzzyFindResult }; + +/** + * Fuzzy file path search. + * + * Searches for files and directories whose paths match the query string. + * Results are sorted by match quality (higher score = better match). + * + * Scoring tiers (highest to lowest): + * - 120: exact filename match + * - 100: filename starts with query + * - 80: filename contains query + * - 60: full path contains query + * - 50-90: fuzzy subsequence match on filename + * - 30-70: fuzzy subsequence match on full path + * + * Directories receive a +10 score bonus. + */ +export function fuzzyFind(options: FuzzyFindOptions): FuzzyFindResult { + return native.fuzzyFind(options) as FuzzyFindResult; +} diff --git a/packages/native/src/fd/types.ts b/packages/native/src/fd/types.ts new file mode 100644 index 000000000..dacbe7dca --- /dev/null +++ b/packages/native/src/fd/types.ts @@ -0,0 +1,31 @@ +/** Options for fuzzy file path search. */ +export interface FuzzyFindOptions { + /** Fuzzy query to match against file paths (case-insensitive). */ + query: string; + /** Directory to search. */ + path: string; + /** Include hidden files (default: false). */ + hidden?: boolean; + /** Respect .gitignore (default: true). */ + gitignore?: boolean; + /** Maximum number of matches to return (default: 100). */ + maxResults?: number; +} + +/** A single match in fuzzy find results. */ +export interface FuzzyFindMatch { + /** Relative path from the search root (uses `/` separators). Directories have a trailing `/`. */ + path: string; + /** Whether this entry is a directory. */ + isDirectory: boolean; + /** Match quality score (higher is better). */ + score: number; +} + +/** Result of fuzzy file path search. */ +export interface FuzzyFindResult { + /** Matched entries (up to `maxResults`), sorted by score descending. */ + matches: FuzzyFindMatch[]; + /** Total number of matches found (may exceed `matches.length`). */ + totalMatches: number; +} diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index 16d52533e..6196f5719 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -9,6 +9,7 @@ * - highlight: syntect-based syntax highlighting * - html: HTML to Markdown conversion * - text: ANSI-aware text measurement and slicing + * - fd: fuzzy file path discovery for autocomplete and @-mention resolution */ export { @@ -70,3 +71,10 @@ export { EllipsisKind, } from "./text/index.js"; export type { SliceResult, ExtractSegmentsResult } from "./text/index.js"; + +export { fuzzyFind } from "./fd/index.js"; +export type { + FuzzyFindMatch, + FuzzyFindOptions, + FuzzyFindResult, +} from "./fd/index.js"; diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index 92e62f82f..3e127a3ff 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -86,4 +86,5 @@ export const native = loadNative() as { ) => unknown; sanitizeText: (text: string) => string; visibleWidth: (text: string, tabWidth?: number) => number; + fuzzyFind: (options: unknown) => unknown; };