merge: integrate native fd module (#231)
This commit is contained in:
parent
8fb8c6a16b
commit
cd444eb0ea
9 changed files with 741 additions and 3 deletions
1
native/Cargo.lock
generated
1
native/Cargo.lock
generated
|
|
@ -157,6 +157,7 @@ name = "gsd-engine"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"gsd-grep",
|
||||
"ignore",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
|
|
|
|||
494
native/crates/engine/src/fd.rs
Normal file
494
native/crates/engine/src/fd.rs
Normal file
|
|
@ -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<bool>,
|
||||
/// Respect .gitignore (default: true).
|
||||
pub gitignore: Option<bool>,
|
||||
/// Maximum number of matches to return (default: 100).
|
||||
#[napi(js_name = "maxResults")]
|
||||
pub max_results: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<FuzzyFindMatch>,
|
||||
/// 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<std::path::PathBuf> {
|
||||
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<usize> = 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<WalkEntry> {
|
||||
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<FuzzyFindResult> {
|
||||
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<char> = 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<FuzzyFindMatch> = 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<char> = "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<char> = "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<char> = "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<char> = 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::<Vec<_>>(),
|
||||
);
|
||||
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::<Vec<_>>(),
|
||||
);
|
||||
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::<Vec<_>>(),
|
||||
);
|
||||
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::<Vec<_>>(),
|
||||
);
|
||||
let dir_score = score_fuzzy_path(
|
||||
"src/main.rs",
|
||||
true,
|
||||
"main.rs",
|
||||
"mainrs",
|
||||
&"mainrs".chars().collect::<Vec<_>>(),
|
||||
);
|
||||
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::<Vec<_>>(),
|
||||
);
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
164
packages/native/src/__tests__/fd.test.mjs
Normal file
164
packages/native/src/__tests__/fd.test.mjs
Normal file
|
|
@ -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})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
35
packages/native/src/fd/index.ts
Normal file
35
packages/native/src/fd/index.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
31
packages/native/src/fd/types.ts
Normal file
31
packages/native/src/fd/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -86,4 +86,5 @@ export const native = loadNative() as {
|
|||
) => unknown;
|
||||
sanitizeText: (text: string) => string;
|
||||
visibleWidth: (text: string, tabWidth?: number) => number;
|
||||
fuzzyFind: (options: unknown) => unknown;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue