Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcdf4a9172 | |||
| 1001eb8b5e | |||
| f0ce2dd50d | |||
| 1c2a1c1204 | |||
| 66bdfff454 | |||
| 1e50f8df80 |
@@ -20,7 +20,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
# - os: ubuntu-latest TODO uncomment once bun stop crashing
|
||||
- os: macos-latest
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
@@ -31,10 +31,6 @@ use ffi_types::{
|
||||
FffResult, GrepSearchOptionsJson, InitOptions, MultiGrepOptionsJson, ScanProgress,
|
||||
SearchOptions,
|
||||
};
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
/// Opaque fff_handle holding all per-instance state.
|
||||
///
|
||||
|
||||
@@ -858,7 +858,7 @@ fn scan_filesystem(
|
||||
.build_parallel();
|
||||
|
||||
let walker_start = std::time::Instant::now();
|
||||
info!("SCAN: Starting file walker");
|
||||
debug!("SCAN: Starting file walker");
|
||||
|
||||
let files = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
walker.run(|| {
|
||||
@@ -903,6 +903,7 @@ fn scan_filesystem(
|
||||
let frecency = shared_frecency
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
|
||||
files
|
||||
.par_iter_mut()
|
||||
.try_for_each(|file| -> Result<(), Error> {
|
||||
|
||||
@@ -167,7 +167,10 @@ impl FrecencyTracker {
|
||||
}
|
||||
}
|
||||
if read_errors > 0 {
|
||||
tracing::warn!(read_errors, "Skipped corrupted entries during compaction read");
|
||||
tracing::warn!(
|
||||
read_errors,
|
||||
"Skipped corrupted entries during compaction read"
|
||||
);
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
@@ -1556,7 +1556,10 @@ pub fn grep_search<'a>(
|
||||
prepare_files_to_search(files, constraints_from_query, options);
|
||||
|
||||
// If constraints yielded 0 files and we had a FilePath constraint,
|
||||
// retry without it (the path token was likely part of the search text).
|
||||
// retry without it — the filename may not exist in this repo.
|
||||
// Keep the original grep_text (e.g. "ActorAuth") rather than restoring
|
||||
// the raw query ("nonexistent.rs ActorAuth"), since the search term
|
||||
// was correctly extracted by the parser.
|
||||
if files_to_search.is_empty()
|
||||
&& let Some(stripped) = strip_file_path_constraints(constraints_from_query)
|
||||
{
|
||||
|
||||
@@ -327,6 +327,7 @@ pub fn match_and_score_files<'a>(
|
||||
match_type: match filename_match {
|
||||
Some(filename_match) if filename_match.exact => "exact_filename",
|
||||
Some(_) => "fuzzy_filename",
|
||||
None if path_match.exact => "exact_path",
|
||||
None => "fuzzy_path",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ use clap::Parser;
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::{FFFMode, SharedFrecency, SharedPicker};
|
||||
use git2::Repository;
|
||||
use mimalloc::MiMalloc;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
use server::FffServer;
|
||||
@@ -70,13 +71,15 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
|
||||
"For multi_grep: constraints go in the separate 'constraints' parameter.\n",
|
||||
"\n",
|
||||
"Constraints MUST match one of these formats:\n",
|
||||
" Extension: '*.rs', '*.{ts,tsx}' (starts with *.)\n",
|
||||
" Directory: 'src/', 'quotes/' (ends with /)\n",
|
||||
" Exclude: '!test/', '!*.spec.ts' (starts with !)\n",
|
||||
" Extension: '*.rs', '*.{ts,tsx}'\n",
|
||||
" Directory: 'src/', 'quotes/'\n",
|
||||
" Filename: 'schema.rs', 'src/main.rs'\n",
|
||||
" Exclude: '!test/', '!*.spec.ts'\n",
|
||||
"\n",
|
||||
"! Bare words are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
|
||||
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
|
||||
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
|
||||
"! Bare words without extensions are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
|
||||
" + 'schema.rs TODO' -> searches for 'TODO' in files schema.rs\n",
|
||||
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
|
||||
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n",
|
||||
"\n",
|
||||
"Prefer broad constraints:\n",
|
||||
" + '*.rs query' -> file type\n",
|
||||
@@ -100,7 +103,7 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
|
||||
|
||||
/// FFF MCP Server — high-performance file finder for AI code assistants.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "fff-mcp", version = env!("CARGO_PKG_VERSION"))]
|
||||
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
|
||||
struct Args {
|
||||
/// Base directory to index. Defaults to the current working directory.
|
||||
#[arg(value_name = "PATH")]
|
||||
@@ -205,6 +208,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
if Repository::discover(&base_path).is_err() {
|
||||
tracing::error!("MCP server must be run within a Git repository");
|
||||
return Err(format!("Not a Git repository: {}", base_path).into());
|
||||
}
|
||||
|
||||
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
|
||||
|
||||
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
|
||||
use fff_core::types::{FileItem, PaginationArgs};
|
||||
use fff_core::{Constraint, FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff_core::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
|
||||
use fff_query_parser::AiGrepConfig;
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
@@ -21,12 +21,12 @@ use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
|
||||
use crate::cursor::CursorStore;
|
||||
use crate::output::{GrepFormatter, OutputMode, file_suffix};
|
||||
|
||||
/// Strip common delimiters for fuzzy fallback queries.
|
||||
fn strip_delimiters(s: &str) -> String {
|
||||
/// Strip common delimiters and lowercase for fuzzy fallback queries.
|
||||
fn cleanup_fuzzy_query(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if !matches!(c, ':' | '-' | '_') {
|
||||
out.push(c);
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -305,7 +305,7 @@ impl FffServer {
|
||||
}
|
||||
|
||||
// Fuzzy fallback for typo tolerance
|
||||
let fuzzy_query = strip_delimiters(&query.to_lowercase());
|
||||
let fuzzy_query = cleanup_fuzzy_query(query);
|
||||
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
|
||||
let fuzzy_parsed = parser.parse(&fuzzy_query);
|
||||
let fuzzy_result =
|
||||
@@ -331,35 +331,40 @@ impl FffServer {
|
||||
)]));
|
||||
}
|
||||
|
||||
let hint = match &parsed {
|
||||
Some(q)
|
||||
if q.constraints
|
||||
.iter()
|
||||
.any(|c| matches!(c, Constraint::FilePath(_))) =>
|
||||
// File path fallback: if query looks like a path, suggest the matching file
|
||||
if query.contains('/') {
|
||||
let file_parser = QueryParser::default();
|
||||
let file_query = file_parser.parse(query);
|
||||
let file_opts = FuzzySearchOptions {
|
||||
max_threads: 0,
|
||||
current_file: None,
|
||||
project_path: Some(picker.base_path()),
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
};
|
||||
let file_result = FilePicker::fuzzy_search(files, query, file_query, file_opts);
|
||||
if let (Some(top), Some(score)) =
|
||||
(file_result.items.first(), file_result.scores.first())
|
||||
{
|
||||
let path = q
|
||||
.constraints
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
Constraint::FilePath(p) => Some(*p),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let ext = path.rsplit('.').next().unwrap_or("");
|
||||
format!(
|
||||
" Constraint '{path}' looks like a file path — use Read to search in a specific file, or '*.{ext}' for extension filter."
|
||||
)
|
||||
// Only suggest when the match is strong enough.
|
||||
let query_len = query.len() as i32;
|
||||
if score.base_score > query_len * 10 {
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 content matches. But there is a relevant file path: {}",
|
||||
top.relative_path
|
||||
))]));
|
||||
}
|
||||
}
|
||||
Some(q) if !q.constraints.is_empty() && !q.grep_text().is_empty() => {
|
||||
" Try to omit constraint".to_string()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(format!(
|
||||
"0 matches {}",
|
||||
hint
|
||||
))]));
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
"0 matches.".to_string(),
|
||||
)]));
|
||||
}
|
||||
|
||||
if result.matches.is_empty() {
|
||||
|
||||
@@ -34,7 +34,7 @@ impl PathCache {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size))]
|
||||
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size), level = tracing::Level::TRACE)]
|
||||
fn get(&self, path: &Path, max_size: usize) -> Option<&str> {
|
||||
self.map.get(path).and_then(|entry| {
|
||||
// Only return cached value if max_size matches
|
||||
@@ -85,7 +85,7 @@ pub fn shorten_path_with_cache(
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
|
||||
if let Some(cached) = cache.get(path, max_size) {
|
||||
tracing::debug!("Cache hit for path '{}'", path.display());
|
||||
tracing::trace!("Cache hit for path '{}'", path.display());
|
||||
return Ok(cached.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,13 @@ pub trait ParserConfig {
|
||||
true
|
||||
}
|
||||
|
||||
/// Should parse location suffixes (e.g., file:12, file:12:4)
|
||||
/// Disabled for grep modes where colon-number patterns like localhost:8080
|
||||
/// are search text, not file locations.
|
||||
fn enable_location(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Determine whether a token should be treated as a glob constraint.
|
||||
///
|
||||
/// The default implementation delegates to `zlob::has_wildcards` with
|
||||
@@ -126,6 +133,10 @@ impl ParserConfig for GrepConfig {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_location(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Only recognise globs that are clearly directory/path oriented.
|
||||
///
|
||||
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
|
||||
@@ -161,10 +172,10 @@ impl ParserConfig for GrepConfig {
|
||||
/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
|
||||
/// automatic file-path constraint detection.
|
||||
///
|
||||
/// When an AI agent sends `"libswscale/input.c rgba32ToY"`, the token
|
||||
/// `libswscale/input.c` is detected as a `FilePath` constraint so the
|
||||
/// search is scoped to that file. The caller validates the constraint
|
||||
/// against the index and drops it if no files match (fallback).
|
||||
/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
|
||||
/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
|
||||
/// so the search is scoped to matching files. The caller validates the
|
||||
/// constraint against the index and drops it if no files match (fallback).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AiGrepConfig;
|
||||
|
||||
@@ -177,6 +188,10 @@ impl ParserConfig for AiGrepConfig {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_location(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
// First check GrepConfig's strict rules (path globs, brace expansion)
|
||||
if GrepConfig.is_glob_pattern(token) {
|
||||
@@ -202,36 +217,10 @@ impl ParserConfig for AiGrepConfig {
|
||||
}
|
||||
|
||||
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
|
||||
if is_file_path_token(token) {
|
||||
if is_filename_constraint_token(token) {
|
||||
Some(Constraint::FilePath(token))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_file_path_token(token: &str) -> bool {
|
||||
let bytes = token.as_bytes();
|
||||
|
||||
// Must contain at least one /
|
||||
if !bytes.contains(&b'/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must NOT end with / (that's a PathSegment)
|
||||
if bytes.last() == Some(&b'/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must NOT contain wildcards (those are globs)
|
||||
if has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Last component must contain . (file extension)
|
||||
match token.rsplit('/').next() {
|
||||
Some(last) => last.contains('.'),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +58,15 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
}
|
||||
|
||||
// Try to extract location from single token (e.g., "file:12")
|
||||
let (query_without_loc, location) = parse_location(query);
|
||||
if location.is_some() {
|
||||
return Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query: FuzzyQuery::Text(query_without_loc),
|
||||
location,
|
||||
});
|
||||
if config.enable_location() {
|
||||
let (query_without_loc, location) = parse_location(query);
|
||||
if location.is_some() {
|
||||
return Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query: FuzzyQuery::Text(query_without_loc),
|
||||
location,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Plain text single token - return None (caller handles as simple fuzzy match)
|
||||
@@ -99,7 +101,7 @@ impl<C: ParserConfig> QueryParser<C> {
|
||||
|
||||
// Try to extract location from the last fuzzy token
|
||||
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
|
||||
let location = if !text_parts.is_empty() {
|
||||
let location = if config.enable_location() && !text_parts.is_empty() {
|
||||
let last_idx = text_parts.len() - 1;
|
||||
let (without_loc, loc) = parse_location(text_parts[last_idx]);
|
||||
if loc.is_some() {
|
||||
@@ -918,13 +920,44 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_no_false_positive_no_slash() {
|
||||
fn test_ai_grep_bare_filename_is_file_path() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("main.rs pattern").expect("Should parse");
|
||||
// No slash → not a file path, just text
|
||||
// Bare filename with valid extension → FilePath constraint
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::FilePath("main.rs")),
|
||||
"Expected FilePath, got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.grep_text(), "pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_bare_filename_schema_rs() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser
|
||||
.parse("schema.rs part_revisions")
|
||||
.expect("Should parse");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(
|
||||
matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
|
||||
"Expected FilePath(schema.rs), got {:?}",
|
||||
result.constraints[0]
|
||||
);
|
||||
assert_eq!(result.grep_text(), "part_revisions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_bare_word_no_extension_not_constraint() {
|
||||
use crate::AiGrepConfig;
|
||||
let parser = QueryParser::new(AiGrepConfig);
|
||||
let result = parser.parse("schema pattern").expect("Should parse");
|
||||
// No extension → not a file path, just text
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
assert_eq!(result.grep_text(), "main.rs pattern");
|
||||
assert_eq!(result.grep_text(), "schema pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -979,6 +1012,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_no_location_parsing_single_token() {
|
||||
let parser = QueryParser::new(GrepConfig);
|
||||
// localhost:8080 should NOT be parsed as location — it's a search pattern
|
||||
let result = parser.parse("localhost:8080");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"Single-token grep query with colon-number should return None (plain text), got {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_no_location_parsing_multi_token() {
|
||||
let q = QueryParser::new(GrepConfig)
|
||||
.parse("*.rs localhost:8080")
|
||||
.expect("should parse");
|
||||
assert_eq!(
|
||||
q.grep_text(),
|
||||
"localhost:8080",
|
||||
"Colon-number suffix should be preserved in grep text"
|
||||
);
|
||||
assert!(
|
||||
q.location.is_none(),
|
||||
"Grep should not parse location from colon-number"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grep_config_star_text_star_not_glob() {
|
||||
use crate::GrepConfig;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 12
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 13
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
|
||||
+24
-6
@@ -126,7 +126,9 @@ download_binary() {
|
||||
mv "${tmp_dir}/${filename}" "${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
chmod +x "${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
|
||||
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
if [ "$IS_UPDATE" != true ]; then
|
||||
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_path() {
|
||||
@@ -236,19 +238,35 @@ print_setup_instructions() {
|
||||
}
|
||||
|
||||
main() {
|
||||
info "Installing FFF MCP Server..."
|
||||
echo ""
|
||||
|
||||
local target
|
||||
target="$(detect_platform)"
|
||||
|
||||
local existing_binary="${INSTALL_DIR}/${BINARY_NAME}"
|
||||
IS_UPDATE=false
|
||||
|
||||
if [ -x "$existing_binary" ]; then
|
||||
IS_UPDATE=true
|
||||
info "Updating FFF MCP Server..."
|
||||
else
|
||||
info "Installing FFF MCP Server..."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
info "Detected platform: ${target}"
|
||||
|
||||
local tag
|
||||
tag="$(get_latest_release_tag "$target")"
|
||||
|
||||
download_binary "$target" "$tag"
|
||||
check_path
|
||||
print_setup_instructions
|
||||
|
||||
if [ "$IS_UPDATE" = true ]; then
|
||||
echo ""
|
||||
success "FFF MCP Server updated to ${tag}!"
|
||||
echo ""
|
||||
else
|
||||
check_path
|
||||
print_setup_instructions
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
|
||||
@@ -114,7 +114,7 @@ local function download_from_github(version, binary_path, opts, callback)
|
||||
extra_curl_args = opts.extra_curl_args,
|
||||
}, function(success, err)
|
||||
if not success then
|
||||
vim.uv.fanoushkas_unlink(tmp_path)
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, err)
|
||||
return
|
||||
end
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"workspaces": ["packages/fff-bun", "packages/fff-mcp"],
|
||||
"packageManager": "bun@1.3.9",
|
||||
"scripts": {
|
||||
"format": "biome format --write",
|
||||
"format:check": "biome format",
|
||||
@@ -11,4 +12,4 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-37
@@ -148,9 +148,7 @@ function snakeToCamel(obj: unknown): unknown {
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
|
||||
letter.toUpperCase(),
|
||||
);
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
result[camelKey] = snakeToCamel(value);
|
||||
}
|
||||
return result;
|
||||
@@ -298,15 +296,9 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<unknown> {
|
||||
/**
|
||||
* Wait for scan to complete.
|
||||
*/
|
||||
export function ffiWaitForScan(
|
||||
handle: NativeHandle,
|
||||
timeoutMs: number,
|
||||
): Result<boolean> {
|
||||
export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_wait_for_scan(
|
||||
handle,
|
||||
BigInt(timeoutMs),
|
||||
);
|
||||
const resultPtr = library.symbols.fff_wait_for_scan(handle, BigInt(timeoutMs));
|
||||
const result = parseResult<boolean | string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
// JSON.parse("true") returns boolean true, but we also handle
|
||||
@@ -317,15 +309,9 @@ export function ffiWaitForScan(
|
||||
/**
|
||||
* Restart index in new path.
|
||||
*/
|
||||
export function ffiRestartIndex(
|
||||
handle: NativeHandle,
|
||||
newPath: string,
|
||||
): Result<void> {
|
||||
export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result<void> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_restart_index(
|
||||
handle,
|
||||
ptr(encodeString(newPath)),
|
||||
);
|
||||
const resultPtr = library.symbols.fff_restart_index(handle, ptr(encodeString(newPath)));
|
||||
return parseResult<void>(resultPtr);
|
||||
}
|
||||
|
||||
@@ -340,10 +326,7 @@ export function ffiRefreshGitStatus(handle: NativeHandle): Result<number> {
|
||||
// JSON.parse("3") returns 3 (number), parseInt handles both
|
||||
return {
|
||||
ok: true,
|
||||
value:
|
||||
typeof result.value === "number"
|
||||
? result.value
|
||||
: parseInt(result.value, 10),
|
||||
value: typeof result.value === "number" ? result.value : parseInt(result.value, 10),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,14 +357,10 @@ export function ffiGetHistoricalQuery(
|
||||
offset: number,
|
||||
): Result<string | null> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_get_historical_query(
|
||||
handle,
|
||||
BigInt(offset),
|
||||
);
|
||||
const resultPtr = library.symbols.fff_get_historical_query(handle, BigInt(offset));
|
||||
const result = parseResult<string | null>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
if (result.value === null || result.value === "null")
|
||||
return { ok: true, value: null };
|
||||
if (result.value === null || result.value === "null") return { ok: true, value: null };
|
||||
return result as Result<string>;
|
||||
}
|
||||
|
||||
@@ -431,15 +410,9 @@ export function ffiLiveGrep(
|
||||
/**
|
||||
* Multi-pattern grep - Aho-Corasick multi-needle search.
|
||||
*/
|
||||
export function ffiMultiGrep(
|
||||
handle: NativeHandle,
|
||||
optsJson: string,
|
||||
): Result<unknown> {
|
||||
export function ffiMultiGrep(handle: NativeHandle, optsJson: string): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_multi_grep(
|
||||
handle,
|
||||
ptr(encodeString(optsJson)),
|
||||
);
|
||||
const resultPtr = library.symbols.fff_multi_grep(handle, ptr(encodeString(optsJson)));
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user