From 735dc17d5c4a2d5ac00c320d40ce8d240fc62c5c Mon Sep 17 00:00:00 2001 From: 4fu Date: Sun, 14 Jun 2026 19:02:49 +0800 Subject: [PATCH 01/15] feat(python): migrate Python bindings from ctypes to PyO3 - Add native PyO3 extension in crates/fff-python - Replace ctypes wrapper with maturin-built package in packages/fff-python - Expose FileFinder, search/glob/directory/mixed/grep APIs and result types - Use PyPI package name fff-python (import name remains fff) - Update workspace Cargo.toml/Cargo.lock and .gitignore for Python artifacts --- .gitignore | 32 + Cargo.lock | 110 ++ Cargo.toml | 2 + crates/fff-python/Cargo.toml | 19 + crates/fff-python/src/lib.rs | 1160 ++++++++++++++++++++++ packages/fff-python/.python-version | 1 + packages/fff-python/README.md | 53 + packages/fff-python/examples/basic.py | 43 + packages/fff-python/pyproject.toml | 31 + packages/fff-python/src/fff/__init__.py | 40 + packages/fff-python/tests/test_finder.py | 84 ++ packages/fff-python/uv.lock | 208 ++++ 12 files changed, 1783 insertions(+) create mode 100644 crates/fff-python/Cargo.toml create mode 100644 crates/fff-python/src/lib.rs create mode 100644 packages/fff-python/.python-version create mode 100644 packages/fff-python/README.md create mode 100644 packages/fff-python/examples/basic.py create mode 100644 packages/fff-python/pyproject.toml create mode 100644 packages/fff-python/src/fff/__init__.py create mode 100644 packages/fff-python/tests/test_finder.py create mode 100644 packages/fff-python/uv.lock diff --git a/.gitignore b/.gitignore index 26183ffb..4d026b32 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,41 @@ scripts/benchmark-results/ *.dylib *.so *.dll +*.pdb # Instruments traces *.trace/ # Test logs fff-test.log + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.pyd +*.egg-info/ +*.egg +.eggs/ +build/ +*.whl +# Virtual environments +.venv/ +venv/ +env/ +ENV/ +# uv +# Testing / linting +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ diff --git a/Cargo.lock b/Cargo.lock index 7eb9d48f..280542de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -699,6 +699,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "fff-python" +version = "0.1.0" +dependencies = [ + "fff-query-parser", + "fff-search", + "git2", + "pyo3", + "serde_json", +] + [[package]] name = "fff-query-parser" version = "0.9.4" @@ -1191,6 +1202,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.11.1" @@ -1435,6 +1455,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mimalloc" version = "0.1.48" @@ -1778,6 +1807,12 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1838,6 +1873,69 @@ dependencies = [ "unarray", ] +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -2299,6 +2397,12 @@ dependencies = [ "syn", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -2544,6 +2648,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "url" version = "2.5.8" diff --git a/Cargo.toml b/Cargo.toml index 64454117..3138ac20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,11 @@ members = [ "crates/fff-core", "crates/fff-mcp", "crates/fff-nvim", + "crates/fff-python", "crates/fff-query-parser", "crates/fff-grep", ] + resolver = "2" [workspace.dependencies] diff --git a/crates/fff-python/Cargo.toml b/crates/fff-python/Cargo.toml new file mode 100644 index 00000000..e56b6ce5 --- /dev/null +++ b/crates/fff-python/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fff-python" +version = "0.1.0" +edition = "2024" + +[lib] +name = "fff_python" +crate-type = ["cdylib"] + +[features] +default = [] +zlob = ["fff/zlob"] + +[dependencies] +fff = { package = "fff-search", path = "../fff-core", version = "0.9.4" } +fff-query-parser = { path = "../fff-query-parser", version = "0.9.4" } +git2 = { workspace = true } +pyo3 = { version = "0.24.0", features = ["extension-module", "abi3-py310"] } +serde_json = "1.0" diff --git a/crates/fff-python/src/lib.rs b/crates/fff-python/src/lib.rs new file mode 100644 index 00000000..34ddf0b2 --- /dev/null +++ b/crates/fff-python/src/lib.rs @@ -0,0 +1,1160 @@ +use std::path::PathBuf; +use std::time::Duration; + +use fff::file_picker::FilePicker; +use fff::frecency::FrecencyTracker; +use fff::query_tracker::QueryTracker; +use fff::{ + FFFMode, FilePickerOptions, FuzzySearchOptions, GrepSearchOptions, PaginationArgs, QueryParser, + SharedFilePicker, SharedFrecency, SharedQueryTracker, +}; +use pyo3::create_exception; +use pyo3::prelude::*; + +create_exception!(fff_python, FFFException, pyo3::exceptions::PyException); + +fn py_err(e: E) -> PyErr { + PyErr::new::(format!("{}", e)) +} + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +#[pyclass] +#[derive(Clone)] +pub struct Score { + #[pyo3(get)] + pub total: i32, + #[pyo3(get)] + pub base_score: i32, + #[pyo3(get)] + pub filename_bonus: i32, + #[pyo3(get)] + pub special_filename_bonus: i32, + #[pyo3(get)] + pub frecency_boost: i32, + #[pyo3(get)] + pub distance_penalty: i32, + #[pyo3(get)] + pub current_file_penalty: i32, + #[pyo3(get)] + pub combo_match_boost: i32, + #[pyo3(get)] + pub path_alignment_bonus: i32, + #[pyo3(get)] + pub exact_match: bool, + #[pyo3(get)] + pub match_type: String, +} + +impl From<&fff::Score> for Score { + fn from(s: &fff::Score) -> Self { + Self { + total: s.total, + base_score: s.base_score, + filename_bonus: s.filename_bonus, + special_filename_bonus: s.special_filename_bonus, + frecency_boost: s.frecency_boost, + distance_penalty: s.distance_penalty, + current_file_penalty: s.current_file_penalty, + combo_match_boost: s.combo_match_boost, + path_alignment_bonus: s.path_alignment_bonus, + exact_match: s.exact_match, + match_type: s.match_type.to_string(), + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct FileItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub file_name: String, + #[pyo3(get)] + pub git_status: String, + #[pyo3(get)] + pub size: u64, + #[pyo3(get)] + pub modified: u64, + #[pyo3(get)] + pub access_frecency_score: i64, + #[pyo3(get)] + pub modification_frecency_score: i64, + #[pyo3(get)] + pub total_frecency_score: i64, + #[pyo3(get)] + pub is_binary: bool, +} + +impl FileItem { + fn from_core(item: &fff::FileItem, picker: &FilePicker) -> Self { + Self { + relative_path: item.relative_path(picker), + file_name: item.file_name(picker), + git_status: fff::git::format_git_status(item.git_status).to_string(), + size: item.size, + modified: item.modified, + access_frecency_score: item.access_frecency_score as i64, + modification_frecency_score: item.modification_frecency_score as i64, + total_frecency_score: item.total_frecency_score() as i64, + is_binary: item.is_binary(), + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct DirItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub dir_name: String, + #[pyo3(get)] + pub max_access_frecency: i32, +} + +impl DirItem { + fn from_core(item: &fff::DirItem, picker: &FilePicker) -> Self { + Self { + relative_path: item.relative_path(picker), + dir_name: item.dir_name(picker), + max_access_frecency: item.max_access_frecency(), + } + } +} + +#[pyclass] +pub struct MixedFileItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub file_name: String, + #[pyo3(get)] + pub git_status: String, + #[pyo3(get)] + pub size: u64, + #[pyo3(get)] + pub modified: u64, + #[pyo3(get)] + pub access_frecency_score: i64, + #[pyo3(get)] + pub modification_frecency_score: i64, + #[pyo3(get)] + pub total_frecency_score: i64, + #[pyo3(get)] + pub is_binary: bool, +} + +#[pyclass] +pub struct MixedDirItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub dir_name: String, + #[pyo3(get)] + pub max_access_frecency: i64, +} + +#[pyclass] +#[derive(Clone)] +pub struct MatchRange { + #[pyo3(get)] + pub start: u32, + #[pyo3(get)] + pub end: u32, +} + +#[pyclass] +#[derive(Clone)] +pub struct GrepMatch { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub file_name: String, + #[pyo3(get)] + pub git_status: String, + #[pyo3(get)] + pub line_content: String, + #[pyo3(get)] + pub match_ranges: Vec, + #[pyo3(get)] + pub context_before: Vec, + #[pyo3(get)] + pub context_after: Vec, + #[pyo3(get)] + pub size: u64, + #[pyo3(get)] + pub modified: u64, + #[pyo3(get)] + pub total_frecency_score: i64, + #[pyo3(get)] + pub access_frecency_score: i64, + #[pyo3(get)] + pub modification_frecency_score: i64, + #[pyo3(get)] + pub line_number: u64, + #[pyo3(get)] + pub byte_offset: u64, + #[pyo3(get)] + pub col: u32, + #[pyo3(get)] + pub fuzzy_score: Option, + #[pyo3(get)] + pub is_definition: bool, + #[pyo3(get)] + pub is_binary: bool, +} + +impl GrepMatch { + fn from_core(m: &fff::GrepMatch, file: &fff::FileItem, picker: &FilePicker) -> Self { + Self { + relative_path: file.relative_path(picker), + file_name: file.file_name(picker), + git_status: fff::git::format_git_status(file.git_status).to_string(), + line_content: m.line_content.clone(), + match_ranges: m + .match_byte_offsets + .iter() + .map(|&(s, e)| MatchRange { start: s, end: e }) + .collect(), + context_before: m.context_before.clone(), + context_after: m.context_after.clone(), + size: file.size, + modified: file.modified, + total_frecency_score: file.total_frecency_score() as i64, + access_frecency_score: file.access_frecency_score as i64, + modification_frecency_score: file.modification_frecency_score as i64, + line_number: m.line_number, + byte_offset: m.byte_offset, + col: m.col as u32, + fuzzy_score: m.fuzzy_score, + is_definition: m.is_definition, + is_binary: file.is_binary(), + } + } +} + +#[pyclass] +pub struct SearchResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub scores: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_files: u32, +} + +#[pyclass] +pub struct DirSearchResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub scores: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_dirs: u32, +} + +#[pyclass] +pub struct MixedSearchResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub scores: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_files: u32, + #[pyo3(get)] + pub total_dirs: u32, +} + +#[pyclass] +pub struct GrepResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_files_searched: u32, + #[pyo3(get)] + pub total_files: u32, + #[pyo3(get)] + pub filtered_file_count: u32, + #[pyo3(get)] + pub next_file_offset: u32, + #[pyo3(get)] + pub regex_fallback_error: Option, +} + +#[pyclass] +pub struct ScanProgress { + #[pyo3(get)] + pub scanned_files_count: u64, + #[pyo3(get)] + pub is_scanning: bool, + #[pyo3(get)] + pub is_watcher_ready: bool, + #[pyo3(get)] + pub is_warmup_complete: bool, +} + +#[pyclass] +pub struct GrepCursor { + #[pyo3(get)] + pub offset: u32, +} + +// --------------------------------------------------------------------------- +// FileFinder +// --------------------------------------------------------------------------- + +#[pyclass] +pub struct FileFinder { + picker: SharedFilePicker, + frecency: SharedFrecency, + query_tracker: SharedQueryTracker, +} + +impl Drop for FileFinder { + fn drop(&mut self) { + if let Ok(mut guard) = self.picker.write() { + guard.take(); + } + if let Ok(mut guard) = self.frecency.write() { + *guard = None; + } + if let Ok(mut guard) = self.query_tracker.write() { + *guard = None; + } + } +} + +#[pymethods] +impl FileFinder { + #[new] + #[pyo3(signature = ( + base_path, + frecency_db_path=None, + history_db_path=None, + enable_mmap_cache=true, + enable_content_indexing=true, + watch=true, + ai_mode=false, + log_file_path=None, + log_level=None, + cache_budget_max_files=0, + cache_budget_max_bytes=0, + cache_budget_max_file_size=0, + enable_fs_root_scanning=false, + enable_home_dir_scanning=false, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + base_path: &str, + frecency_db_path: Option, + history_db_path: Option, + enable_mmap_cache: bool, + enable_content_indexing: bool, + watch: bool, + ai_mode: bool, + log_file_path: Option, + log_level: Option, + cache_budget_max_files: u64, + cache_budget_max_bytes: u64, + cache_budget_max_file_size: u64, + enable_fs_root_scanning: bool, + enable_home_dir_scanning: bool, + ) -> PyResult { + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::default(); + let query_tracker = SharedQueryTracker::default(); + + if let Some(path) = frecency_db_path { + let parent = PathBuf::from(&path).parent().map(PathBuf::from); + if let Some(p) = parent { + let _ = std::fs::create_dir_all(p); + } + let tracker = FrecencyTracker::open(&path).map_err(py_err)?; + shared_frecency.init(tracker).map_err(py_err)?; + } + + if let Some(path) = history_db_path { + let parent = PathBuf::from(&path).parent().map(PathBuf::from); + if let Some(p) = parent { + let _ = std::fs::create_dir_all(p); + } + let tracker = QueryTracker::open(&path).map_err(py_err)?; + query_tracker.init(tracker).map_err(py_err)?; + } + + if let Some(path) = log_file_path { + let level = log_level.as_deref(); + fff::log::init_tracing(&path, level, None).map_err(py_err)?; + } + + let mode = if ai_mode { + FFFMode::Ai + } else { + FFFMode::Neovim + }; + + let cache_budget = fff::ContentCacheBudget::from_overrides( + cache_budget_max_files as usize, + cache_budget_max_bytes, + cache_budget_max_file_size, + ); + + FilePicker::new_with_shared_state( + shared_picker.clone(), + shared_frecency.clone(), + FilePickerOptions { + base_path: base_path.to_string(), + enable_mmap_cache, + enable_content_indexing, + watch, + mode, + cache_budget, + follow_symlinks: false, + enable_fs_root_scanning, + enable_home_dir_scanning, + }, + ) + .map_err(py_err)?; + + Ok(Self { + picker: shared_picker, + frecency: shared_frecency, + query_tracker, + }) + } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) {} + + fn destroy(&mut self) -> PyResult<()> { + if let Ok(mut guard) = self.picker.write() { + *guard = None; + } + if let Ok(mut guard) = self.frecency.write() { + *guard = None; + } + if let Ok(mut guard) = self.query_tracker.write() { + *guard = None; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + query, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + combo_boost_multiplier=0, + min_combo_count=0, + ))] + fn search( + &self, + query: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + combo_boost_multiplier: i32, + min_combo_count: u32, + ) -> PyResult { + let picker_guard = self.picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let qt_guard = self.query_tracker.read().map_err(py_err)?; + + let parser = QueryParser::default(); + let parsed = parser.parse(query); + let result = picker.fuzzy_search( + &parsed, + qt_guard.as_ref(), + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier: combo_boost_multiplier, + min_combo_count, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, + }, + ); + + let items: Vec = result + .items + .iter() + .map(|i| FileItem::from_core(i, picker)) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(SearchResult { + items, + scores, + total_matched: result.total_matched as u32, + total_files: result.total_files as u32, + }) + } + + #[pyo3(signature = ( + pattern, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + ))] + fn glob( + &self, + pattern: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + ) -> PyResult { + let picker_guard = self.picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let result = picker.glob( + pattern, + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier: 0, + min_combo_count: 0, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, + }, + ); + + let items: Vec = result + .items + .iter() + .map(|i| FileItem::from_core(i, picker)) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(SearchResult { + items, + scores, + total_matched: result.total_matched as u32, + total_files: result.total_files as u32, + }) + } + + #[pyo3(signature = ( + query, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + ))] + fn directory_search( + &self, + query: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + ) -> PyResult { + let picker_guard = self.picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let parser = QueryParser::new(fff_query_parser::DirSearchConfig); + let parsed = parser.parse(query); + let result = picker.fuzzy_search_directories( + &parsed, + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier: 0, + min_combo_count: 0, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, + }, + ); + + let items: Vec = result + .items + .iter() + .map(|i| DirItem::from_core(i, picker)) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(DirSearchResult { + items, + scores, + total_matched: result.total_matched as u32, + total_dirs: result.total_dirs as u32, + }) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + query, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + combo_boost_multiplier=0, + min_combo_count=0, + ))] + fn mixed_search( + &self, + query: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + combo_boost_multiplier: i32, + min_combo_count: u32, + ) -> PyResult { + let picker_guard = self.picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let qt_guard = self.query_tracker.read().map_err(py_err)?; + + let parser = QueryParser::new(fff_query_parser::MixedSearchConfig); + let parsed = parser.parse(query); + let result = picker.fuzzy_search_mixed( + &parsed, + qt_guard.as_ref(), + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier: combo_boost_multiplier, + min_combo_count, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, + }, + ); + + Python::with_gil(|py| { + let items: PyResult> = result + .items + .iter() + .map(|item| match item { + fff::MixedItemRef::File(file) => { + let it = FileItem::from_core(file, picker); + Ok(Py::new( + py, + MixedFileItem { + relative_path: it.relative_path, + file_name: it.file_name, + git_status: it.git_status, + size: it.size, + modified: it.modified, + access_frecency_score: it.access_frecency_score, + modification_frecency_score: it.modification_frecency_score, + total_frecency_score: it.total_frecency_score, + is_binary: it.is_binary, + }, + )? + .into_any()) + } + fff::MixedItemRef::Dir(dir) => { + let it = DirItem::from_core(dir, picker); + Ok(Py::new( + py, + MixedDirItem { + relative_path: it.relative_path, + dir_name: it.dir_name, + max_access_frecency: it.max_access_frecency as i64, + }, + )? + .into_any()) + } + }) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + Ok(MixedSearchResult { + items: items?, + scores, + total_matched: result.total_matched as u32, + total_files: result.total_files as u32, + total_dirs: result.total_dirs as u32, + }) + }) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + query, + mode="plain", + max_file_size=0, + max_matches_per_file=0, + smart_case=true, + cursor=None, + page_limit=0, + time_budget_ms=0, + before_context=0, + after_context=0, + classify_definitions=false, + ))] + fn grep( + &self, + query: &str, + mode: &str, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + cursor: Option<&GrepCursor>, + page_limit: u32, + time_budget_ms: u64, + before_context: u32, + after_context: u32, + classify_definitions: bool, + ) -> PyResult { + let picker_guard = self.picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let mode = match mode { + "regex" => fff::GrepMode::Regex, + "fuzzy" => fff::GrepMode::Fuzzy, + _ => fff::GrepMode::PlainText, + }; + + let is_ai = picker.mode().is_ai(); + let parsed = if is_ai { + QueryParser::new(fff_query_parser::AiGrepConfig).parse(query) + } else { + fff::grep::parse_grep_query(query) + }; + + let options = GrepSearchOptions { + max_file_size: if max_file_size == 0 { + 10 * 1024 * 1024 + } else { + max_file_size + }, + max_matches_per_file: max_matches_per_file as usize, + smart_case, + file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), + page_limit: if page_limit == 0 { + 50 + } else { + page_limit as usize + }, + mode, + time_budget_ms, + before_context: before_context as usize, + after_context: after_context as usize, + classify_definitions, + trim_whitespace: false, + abort_signal: None, + }; + + let result = picker.grep(&parsed, &options); + let items: Vec = result + .matches + .iter() + .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) + .collect(); + + Ok(GrepResult { + items, + total_matched: result.matches.len() as u32, + total_files_searched: result.total_files_searched as u32, + total_files: result.total_files as u32, + filtered_file_count: result.filtered_file_count as u32, + next_file_offset: result.next_file_offset as u32, + regex_fallback_error: result.regex_fallback_error, + }) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + patterns, + constraints=None, + max_file_size=0, + max_matches_per_file=0, + smart_case=true, + cursor=None, + page_limit=0, + time_budget_ms=0, + before_context=0, + after_context=0, + classify_definitions=false, + ))] + fn multi_grep( + &self, + patterns: Vec, + constraints: Option, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + cursor: Option<&GrepCursor>, + page_limit: u32, + time_budget_ms: u64, + before_context: u32, + after_context: u32, + classify_definitions: bool, + ) -> PyResult { + let picker_guard = self.picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + if patterns.is_empty() || patterns.iter().all(|p| p.is_empty()) { + return Err(py_err("patterns must not be empty")); + } + let patterns: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect(); + + let is_ai = picker.mode().is_ai(); + let parsed_constraints = constraints.as_ref().map(|c| { + if is_ai { + QueryParser::new(fff_query_parser::AiGrepConfig).parse(c) + } else { + fff::grep::parse_grep_query(c) + } + }); + let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints { + Some(q) => &q.constraints, + None => &[], + }; + + let options = GrepSearchOptions { + max_file_size: if max_file_size == 0 { + 10 * 1024 * 1024 + } else { + max_file_size + }, + max_matches_per_file: max_matches_per_file as usize, + smart_case, + file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), + page_limit: if page_limit == 0 { + 50 + } else { + page_limit as usize + }, + mode: fff::GrepMode::PlainText, + time_budget_ms, + before_context: before_context as usize, + after_context: after_context as usize, + classify_definitions, + trim_whitespace: false, + abort_signal: None, + }; + + let result = picker.multi_grep(&patterns, constraint_refs, &options); + let items: Vec = result + .matches + .iter() + .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) + .collect(); + + Ok(GrepResult { + items, + total_matched: result.matches.len() as u32, + total_files_searched: result.total_files_searched as u32, + total_files: result.total_files as u32, + filtered_file_count: result.filtered_file_count as u32, + next_file_offset: result.next_file_offset as u32, + regex_fallback_error: result.regex_fallback_error, + }) + } + + fn scan_files(&self) -> PyResult<()> { + self.picker + .trigger_full_rescan_async(&self.frecency) + .map_err(py_err) + } + + fn is_scanning(&self) -> PyResult { + let guard = self.picker.read().map_err(py_err)?; + Ok(guard.as_ref().map(|p| p.is_scan_active()).unwrap_or(false)) + } + + fn wait_for_scan(&self, timeout_ms: u64) -> PyResult { + Ok(self.picker.wait_for_scan(Duration::from_millis(timeout_ms))) + } + + fn get_scan_progress(&self) -> PyResult { + let guard = self.picker.read().map_err(py_err)?; + let picker = guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let p = picker.get_scan_progress(); + Ok(ScanProgress { + scanned_files_count: p.scanned_files_count as u64, + is_scanning: p.is_scanning, + is_watcher_ready: p.is_watcher_ready, + is_warmup_complete: p.is_warmup_complete, + }) + } + + fn get_base_path(&self) -> PyResult> { + let guard = self.picker.read().map_err(py_err)?; + Ok(guard + .as_ref() + .map(|p| p.base_path().to_string_lossy().to_string())) + } + + fn reindex(&self, new_path: &str) -> PyResult<()> { + let path = PathBuf::from(new_path); + if !path.exists() { + return Err(py_err(format!("Path does not exist: {}", new_path))); + } + let canonical = fff::path_utils::canonicalize(&path).map_err(py_err)?; + + let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir) = { + let guard = self.picker.write().map_err(py_err)?; + if let Some(ref picker) = *guard { + ( + picker.has_mmap_cache(), + picker.has_content_indexing(), + picker.has_watcher(), + picker.mode(), + picker.fs_root_scanning_enabled(), + picker.home_dir_scanning_enabled(), + ) + } else { + (false, true, true, FFFMode::default(), false, false) + } + }; + + FilePicker::new_with_shared_state( + self.picker.clone(), + self.frecency.clone(), + FilePickerOptions { + base_path: canonical.to_string_lossy().to_string(), + enable_mmap_cache: warmup_caches, + enable_content_indexing: content_indexing, + watch, + mode, + cache_budget: None, + follow_symlinks: false, + enable_fs_root_scanning: fs_root, + enable_home_dir_scanning: home_dir, + }, + ) + .map_err(py_err) + } + + fn refresh_git_status(&self) -> PyResult { + self.picker + .refresh_git_status(&self.frecency) + .map_err(py_err) + .map(|c| c as i64) + } + + #[pyo3(signature = (query, selected_file_path))] + fn track_query(&self, query: &str, selected_file_path: &str) -> PyResult { + let file_path = fff::path_utils::canonicalize(selected_file_path).map_err(py_err)?; + let project_path = { + let guard = self.picker.read().map_err(py_err)?; + guard.as_ref().map(|p| p.base_path().to_path_buf()) + }; + let project_path = match project_path { + Some(p) => p, + None => return Ok(false), + }; + + let mut qt_guard = self.query_tracker.write().map_err(py_err)?; + if let Some(ref mut tracker) = *qt_guard { + tracker + .track_query_completion(query, &project_path, &file_path) + .map_err(py_err)?; + Ok(true) + } else { + Ok(false) + } + } + + fn get_historical_query(&self, offset: u64) -> PyResult> { + let project_path = { + let guard = self.picker.read().map_err(py_err)?; + guard.as_ref().map(|p| p.base_path().to_path_buf()) + }; + let project_path = match project_path { + Some(p) => p, + None => return Ok(None), + }; + + let qt_guard = self.query_tracker.read().map_err(py_err)?; + if let Some(ref tracker) = *qt_guard { + tracker + .get_historical_query(&project_path, offset as usize) + .map_err(py_err) + } else { + Ok(None) + } + } + + #[pyo3(signature = (test_path=None))] + fn health_check(&self, test_path: Option) -> PyResult { + let test_path = test_path + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + + let mut health = serde_json::Map::new(); + health.insert( + "version".to_string(), + serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()), + ); + + let mut git_info = serde_json::Map::new(); + let git_version = git2::Version::get(); + let (major, minor, rev) = git_version.libgit2_version(); + git_info.insert( + "libgit2_version".to_string(), + serde_json::Value::String(format!("{}.{}.{}", major, minor, rev)), + ); + match git2::Repository::discover(&test_path) { + Ok(repo) => { + git_info.insert("available".to_string(), serde_json::Value::Bool(true)); + git_info.insert( + "repository_found".to_string(), + serde_json::Value::Bool(true), + ); + if let Some(workdir) = repo.workdir() { + git_info.insert( + "workdir".to_string(), + serde_json::Value::String(workdir.to_string_lossy().to_string()), + ); + } + } + Err(e) => { + git_info.insert("available".to_string(), serde_json::Value::Bool(true)); + git_info.insert( + "repository_found".to_string(), + serde_json::Value::Bool(false), + ); + git_info.insert( + "error".to_string(), + serde_json::Value::String(e.message().to_string()), + ); + } + } + health.insert("git".to_string(), serde_json::Value::Object(git_info)); + + let mut picker_info = serde_json::Map::new(); + { + let guard = self.picker.read().map_err(py_err)?; + if let Some(ref picker) = *guard { + picker_info.insert("initialized".to_string(), serde_json::Value::Bool(true)); + picker_info.insert( + "base_path".to_string(), + serde_json::Value::String(picker.base_path().to_string_lossy().to_string()), + ); + picker_info.insert( + "is_scanning".to_string(), + serde_json::Value::Bool(picker.is_scan_active()), + ); + let progress = picker.get_scan_progress(); + picker_info.insert( + "indexed_files".to_string(), + serde_json::Value::Number(progress.scanned_files_count.into()), + ); + } else { + picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false)); + } + } + health.insert( + "file_picker".to_string(), + serde_json::Value::Object(picker_info), + ); + + let mut frecency_info = serde_json::Map::new(); + { + let guard = self.frecency.read().map_err(py_err)?; + frecency_info.insert( + "initialized".to_string(), + serde_json::Value::Bool(guard.is_some()), + ); + } + health.insert( + "frecency".to_string(), + serde_json::Value::Object(frecency_info), + ); + + let mut query_info = serde_json::Map::new(); + { + let guard = self.query_tracker.read().map_err(py_err)?; + query_info.insert( + "initialized".to_string(), + serde_json::Value::Bool(guard.is_some()), + ); + } + health.insert( + "query_tracker".to_string(), + serde_json::Value::Object(query_info), + ); + + serde_json::to_string(&health).map_err(|e| py_err(format!("JSON error: {}", e))) + } +} + +// --------------------------------------------------------------------------- +// Module +// --------------------------------------------------------------------------- + +#[pymodule] +fn _fff_python(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add("FFFException", m.py().get_type::())?; + Ok(()) +} diff --git a/packages/fff-python/.python-version b/packages/fff-python/.python-version new file mode 100644 index 00000000..c8cfe395 --- /dev/null +++ b/packages/fff-python/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/packages/fff-python/README.md b/packages/fff-python/README.md new file mode 100644 index 00000000..cf8da9a4 --- /dev/null +++ b/packages/fff-python/README.md @@ -0,0 +1,53 @@ +# fff-python + +Python bindings for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim), built with [PyO3](https://pyo3.rs/) and [Maturin](https://www.maturin.rs/). + +## Requirements + +- Python >= 3.10 +- Rust toolchain (to build the native extension) +- [uv](https://docs.astral.sh/uv/) (recommended) + +## Development setup + +```bash +cd packages/fff-python +uv sync --all-extras +uv run maturin develop --release +``` + +## Running tests + +```bash +cd packages/fff-python +uv run pytest -v +``` + +## Standalone example + +```bash +cd packages/fff-python +uv run python examples/basic.py . +``` + +## Basic usage + +```python +from fff import FileFinder + +with FileFinder("/path/to/project", watch=False) as finder: + finder.wait_for_scan(timeout_ms=5000) + + result = finder.search("main") + for item, score in zip(result.items, result.scores): + print(f"{item.relative_path}: {score.total}") +``` + +## Building wheels + +```bash +cd packages/fff-python +uv run maturin build --release +``` + +The produced wheel is `abi3` compatible with Python 3.10+. diff --git a/packages/fff-python/examples/basic.py b/packages/fff-python/examples/basic.py new file mode 100644 index 00000000..89275d8f --- /dev/null +++ b/packages/fff-python/examples/basic.py @@ -0,0 +1,43 @@ +"""Standalone example of using fff Python bindings.""" + +from __future__ import annotations + +import sys +import time + +from fff import FileFinder + + +def main() -> int: + base_path = sys.argv[1] if len(sys.argv) > 1 else "." + + print(f"Indexing {base_path}...") + start = time.time() + with FileFinder(base_path, watch=False) as finder: + print(f"Created in {time.time() - start:.2f}s") + + print("Waiting for scan...") + finder.wait_for_scan(timeout_ms=30000) + progress = finder.get_scan_progress() + print(f"Indexed {progress.scanned_files_count} files") + + print("\nFuzzy file search for 'main':") + result = finder.search("main", page_size=5) + for item, score in zip(result.items, result.scores): + print(f" {item.relative_path:<50} score={score.total}") + + print("\nGlob search '*.py':") + result = finder.glob("*.py", page_size=5) + for item in result.items: + print(f" {item.relative_path}") + + print("\nGrep for 'def ':") + result = finder.grep("def ", page_limit=5) + for match in result.items: + print(f" {match.relative_path}:{match.line_number}: {match.line_content.strip()}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/fff-python/pyproject.toml b/packages/fff-python/pyproject.toml new file mode 100644 index 00000000..854df444 --- /dev/null +++ b/packages/fff-python/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "fff-python" +version = "0.1.0" +description = "Python bindings for FFF (Fast File Finder)" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +dependencies = [] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "maturin>=1.0", +] + +[project.urls] +Repository = "https://github.com/dmtrKovalenko/fff.nvim" +Issues = "https://github.com/dmtrKovalenko/fff.nvim/issues" + +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[tool.maturin] +manifest-path = "../../crates/fff-python/Cargo.toml" +module-name = "fff._fff_python" +python-source = "src" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/packages/fff-python/src/fff/__init__.py b/packages/fff-python/src/fff/__init__.py new file mode 100644 index 00000000..a51ee768 --- /dev/null +++ b/packages/fff-python/src/fff/__init__.py @@ -0,0 +1,40 @@ +"""Python bindings for FFF (Fast File Finder).""" + +from __future__ import annotations + +from fff._fff_python import ( + DirItem, + DirSearchResult, + FFFException, + FileFinder, + FileItem, + GrepMatch, + GrepResult, + MatchRange, + MixedDirItem, + MixedFileItem, + MixedSearchResult, + ScanProgress, + Score, + SearchResult, +) + +__version__ = "0.1.0" + +__all__ = [ + "FFFException", + "FileFinder", + "FileItem", + "DirItem", + "Score", + "SearchResult", + "DirSearchResult", + "MixedFileItem", + "MixedDirItem", + "MixedSearchResult", + "MatchRange", + "GrepMatch", + "GrepResult", + "ScanProgress", + "__version__", +] diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py new file mode 100644 index 00000000..3eeaf648 --- /dev/null +++ b/packages/fff-python/tests/test_finder.py @@ -0,0 +1,84 @@ +"""Tests for fff Python bindings.""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path + +import pytest + +from fff import FileFinder + + +@pytest.fixture +def sample_dir() -> str: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "src").mkdir() + (root / "src" / "main.py").write_text("def main():\n pass\n") + (root / "src" / "utils.py").write_text("def helper():\n pass\n") + (root / "README.md").write_text("# Sample project\n") + yield str(root) + + +def test_create_and_destroy(sample_dir: str) -> None: + finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False) + assert finder.wait_for_scan(timeout_ms=5000) + assert finder.get_base_path() is not None + finder.destroy() + + +def test_file_search(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + result = finder.search("main") + assert result.total_matched >= 1 + paths = {item.relative_path for item in result.items} + assert any("main.py" in p for p in paths) + + +def test_glob(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + result = finder.glob("*.py") + assert result.total_matched == 2 + + +def test_directory_search(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + result = finder.directory_search("src") + assert result.total_matched >= 1 + assert any("src" in item.relative_path for item in result.items) + + +def test_mixed_search(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + result = finder.mixed_search("main") + assert result.total_matched >= 1 + + +def test_grep(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + result = finder.grep("def main") + assert result.total_matched >= 1 + assert any("main.py" in m.relative_path for m in result.items) + + +def test_multi_grep(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + result = finder.multi_grep(["def main", "def helper"]) + assert result.total_matched >= 2 + + +def test_health_check(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + health = json.loads(finder.health_check()) + assert "version" in health + assert health["file_picker"]["initialized"] is True diff --git a/packages/fff-python/uv.lock b/packages/fff-python/uv.lock new file mode 100644 index 00000000..a6246844 --- /dev/null +++ b/packages/fff-python/uv.lock @@ -0,0 +1,208 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fff-python" +version = "0.1.0" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "maturin" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, +] +provides-extras = ["dev"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "maturin" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/d0/b7c8b7778cc44df3efbc96eb23acaa995e06ea1a60eb9b02f29858fcbd08/maturin-1.14.0.tar.gz", hash = "sha256:f7f82a6aca4a6c402bf00b99200be199d4874d04b9b9e74e825726a3478bba7f", size = 367010, upload-time = "2026-06-12T00:13:30.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/51/49367dcd8f6ec139e69ef0c695c8ff5075223673382101812b4affa53216/maturin-1.14.0-py3-none-linux_armv6l.whl", hash = "sha256:019ea3ec7e71f4c9759a367d4d21022ed5a3a621a2ce123abf3fb114ab3711ca", size = 10204135, upload-time = "2026-06-12T00:13:34.308Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2a/487ce56c838d25e0ce64350e75ec4e3dc89544c0a6233221c229d6aa1a84/maturin-1.14.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6948a10f5f3470b791f79319be51debdd8bfd1778b36f2409f98e1314bc3859b", size = 19736800, upload-time = "2026-06-12T00:13:40.456Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a5/12f2efc18f419edce3282a93629cba16278bb502135dac95cd04ef7c2eae/maturin-1.14.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1506e86b1e273a98074a62e281b13f27ac96f8cdef85f7f98d3e3589a9387a23", size = 10201144, upload-time = "2026-06-12T00:13:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/3789e72273fd8bc80c33a11c787634b3251c4989d7a7203a92438836d4ff/maturin-1.14.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:df10ce4f7ba97fd3423f624f39b94c888ae3e5b470642a91918e1ccec81282fd", size = 10182394, upload-time = "2026-06-12T00:13:13.693Z" }, + { url = "https://files.pythonhosted.org/packages/40/79/15957eb4e055597f217e6310963a9c1371372e63c5b4a3e30803365addd2/maturin-1.14.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:75bcd4468a7fe597652cc2980c6bb16ce4bb8c411e3eb85dac2c4418cef0e95a", size = 10616603, upload-time = "2026-06-12T00:13:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4b/d1822f88cd5e855640f0e10ee00c39b9be614c1ef2f827e9792332d94b9f/maturin-1.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d123337e817f8dfe23755d6760139c01104137bb63e9e20c289c547e25ec857", size = 10075309, upload-time = "2026-06-12T00:13:38.274Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/c1b160d2163e8784489285e82a5c811fdcef3e0704e35b34c1cfe1828de3/maturin-1.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:107f84110d890090a01bb1ecd01761fdfae925c23c659ba492c9b83dd179eab4", size = 10024058, upload-time = "2026-06-12T00:13:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/88a9d1872997d4535af10ebe79f550e834880bf613cf8e50b50d2d938e3b/maturin-1.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:9a84277aa907961cd47ad26fef1539e79efa30611972eaf7499606e773e991b2", size = 13302073, upload-time = "2026-06-12T00:13:29.027Z" }, + { url = "https://files.pythonhosted.org/packages/4a/13/3f6d28bb7b744558b9bc78c995c1855d7e5ff21ad475f46d9de5c3dab039/maturin-1.14.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:095714b2a904927e3c868a1c5d078257ff0443c5049f7623777352966768306e", size = 10863616, upload-time = "2026-06-12T00:13:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/39352d2b402efa3a7dd01d4ed197b301ea35eec10208ba2b8c649101f4df/maturin-1.14.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:20229d332f87166b930e4ca07cdbee8a1726f2eea87a337610aa25bba3ddf4b4", size = 10399943, upload-time = "2026-06-12T00:13:36.273Z" }, + { url = "https://files.pythonhosted.org/packages/58/77/641504541336240fef3836b2d15a785eaeb33c941fb118513c267dd70840/maturin-1.14.0-py3-none-win32.whl", hash = "sha256:4ba1e3c3f33609f461d587b7549104c81a15fd6d42ba63a73cea9376a1e9876e", size = 8905117, upload-time = "2026-06-12T00:13:18.38Z" }, + { url = "https://files.pythonhosted.org/packages/02/4a/ca247a0c43069b2f48cf783c5b13c3a9eb92c8f596dc7fbdb9f75fea4414/maturin-1.14.0-py3-none-win_amd64.whl", hash = "sha256:cb09a313f097adeb4dda0082277871a28d1bd26615dbadab42e6234b6df6fe69", size = 10309099, upload-time = "2026-06-12T00:13:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a4/f14a3f6086cc3caaa90d12e832e4aa41de771c310041959f0d35dd4efe17/maturin-1.14.0-py3-none-win_arm64.whl", hash = "sha256:8c1a8188195f5b6ce1aab99ae2d92e342900298f901456b43ca028947fd3b288", size = 9719100, upload-time = "2026-06-12T00:13:24.741Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] From 7a97802cbe4259c655155e9d0e1d02880d40028c Mon Sep 17 00:00:00 2001 From: 4fu Date: Sun, 14 Jun 2026 19:02:49 +0800 Subject: [PATCH 02/15] ci: add Python CI workflow and release wheel builds - Add .github/workflows/python.yml to test bindings on Ubuntu/macOS/Windows - Extend release.yaml with Python wheel builds (x86_64/aarch64) and sdist - Add optional PyPI publish job using trusted publishing --- .github/workflows/python.yml | 48 +++++++++++ .github/workflows/release.yaml | 150 ++++++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/python.yml diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..43426ef9 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,48 @@ +name: Python CI + +on: + push: + branches: [main] + paths-ignore: + - '**.md' + - 'doc/**' + pull_request: + branches: [main] + paths-ignore: + - '**.md' + - 'doc/**' + +env: + CARGO_TERM_COLOR: always + MACOSX_DEPLOYMENT_TARGET: "13.0" + +jobs: + test: + name: Python bindings (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.11.14" + enable-cache: true + + - name: Build and test Python bindings + working-directory: packages/fff-python + shell: bash + run: | + uv sync --all-extras + uv run maturin develop --release + uv run pytest -v diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a7294925..1c468b86 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,6 +6,13 @@ on: tags: - "v*" pull_request: + workflow_dispatch: + inputs: + publish_pypi: + description: "Publish Python wheels to PyPI (tag pushes only)" + required: false + default: false + type: boolean env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -359,9 +366,97 @@ jobs: name: mcp-${{ matrix.target }} path: fff-mcp-${{ matrix.target }}* + build-python: + name: Build Python wheels ${{ matrix.target }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64 + container: "off" + - os: ubuntu-latest + target: aarch64 + container: "off" + - os: macos-latest + target: x86_64 + - os: macos-latest + target: aarch64 + - os: windows-latest + target: x86_64 + + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + - name: Install cargo-zigbuild + if: contains(matrix.os, 'ubuntu') + run: cargo install cargo-zigbuild + + - name: Install aarch64 cross compiler + if: matrix.target == 'aarch64' && contains(matrix.os, 'ubuntu') + run: | + sudo apt-get update -qq + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + + - name: Build wheels + uses: PyO3/maturin-action@v1 + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc + CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ + AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar + with: + target: ${{ matrix.target }} + args: --release --out dist --features zlob + sccache: "true" + working-directory: packages/fff-python + container: ${{ matrix.container || '' }} + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: python-wheels-${{ matrix.os }}-${{ matrix.target }} + path: packages/fff-python/dist/ + + build-python-sdist: + name: Build Python sdist + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + working-directory: packages/fff-python + + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: python-sdist + path: packages/fff-python/dist/ + release: name: Release - needs: [build-nvim, build-c, build-mcp] + needs: [build-nvim, build-c, build-mcp, build-python, build-python-sdist] runs-on: ubuntu-latest if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v')) permissions: @@ -420,6 +515,20 @@ jobs: rmdir "$dir" 2>/dev/null || true done + - name: Move Python wheels to release directory + working-directory: ./binaries + run: | + mkdir -p python + for dir in python-wheels-*/ python-sdist/; do + [ -d "$dir" ] || continue + for file in "$dir"*; do + if [ -f "$file" ]; then + mv "$file" "python/$(basename "$file")" + fi + done + rmdir "$dir" 2>/dev/null || true + done + - name: Remove npm package artifacts from release binaries working-directory: ./binaries run: | @@ -429,7 +538,7 @@ jobs: working-directory: ./binaries run: | ls -la - for file in *; do + for file in * python/*; do if [ -f "$file" ] && [[ ! "$file" == *.sha256 ]]; then sha256sum "$file" > "${file}.sha256" fi @@ -454,14 +563,16 @@ jobs: name: "${{ steps.version.outputs.version }}" tag_name: "${{ steps.version.outputs.release_tag }}" token: ${{ github.token }} - files: ./binaries/* + files: | + ./binaries/* + ./binaries/python/* draft: false prerelease: ${{ steps.version.outputs.is_release != 'true' }} generate_release_notes: ${{ steps.version.outputs.is_release == 'true' }} body: | ${{ steps.version.outputs.is_release == 'true' && format('Release {0}', steps.version.outputs.version) || format('Nightly release from commit: {0}', github.sha) }} - npm packages and rust crates are available under this version ${{ steps.version.outputs.version }} + npm packages, rust crates and python wheels are available under this version ${{ steps.version.outputs.version }} ## Neovim Plugin - `{target}.so` / `.dylib` / `.dll` - Lua module for Neovim @@ -472,6 +583,10 @@ jobs: ## MCP Server - `fff-mcp-{target}` - MCP server binary + ## Python Package + - `python/*.whl` / `python/*.tar.gz` - Python wheels and sdist + - Install from PyPI: `pip install fff-python` (when published) + Update mcp via: ```sh curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash @@ -498,6 +613,33 @@ jobs: commit_user_name: github-actions[bot] commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com + pypi-publish: + name: Publish Python wheels to PyPI + needs: [release] + runs-on: ubuntu-latest + if: | + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.PUBLISH_TO_PYPI == 'true') || + (github.event_name == 'workflow_dispatch' && inputs.publish_pypi == true) + environment: + name: pypi + url: https://pypi.org/p/fff-python + permissions: + contents: read + id-token: write + steps: + - name: Download Python wheels and sdist + uses: actions/download-artifact@v4 + with: + pattern: python-* + path: dist + merge-multiple: true + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + skip-existing: true + crates-publish: name: Publish Rust crates needs: [build-nvim, build-c, build-mcp] From dd35759eb01154b1dd4a8be6b4bc8d96bdec4c03 Mon Sep 17 00:00:00 2001 From: 4fu Date: Tue, 16 Jun 2026 12:03:28 +0800 Subject: [PATCH 03/15] fix(python): address review feedback and release CI Rust bindings: - Release GIL during heavy search/grep operations via py.allow_threads - Add MixedFileItem/MixedDirItem::from_core to avoid double-cloning - Return PyDict directly from health_check and drop serde_json dependency - Call destroy() in __exit__ so the context manager releases resources - Add mode parameter to multi_grep for parity with grep - Preserve cache budget overrides across reindex() - Rename combo_boost param to match FuzzySearchOptions field Docs/tests: - Update Python test for dict-returning health_check - Add Python bindings section to main README CI: - Fix pypi-publish job to depend on build-python/build-python-sdist instead of release, making the workflow_dispatch checkbox functional --- .github/workflows/release.yaml | 4 +- Cargo.lock | 1 - README.md | 40 ++ crates/fff-python/Cargo.toml | 1 - crates/fff-python/src/lib.rs | 792 +++++++++++++---------- packages/fff-python/tests/test_finder.py | 2 +- 6 files changed, 488 insertions(+), 352 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1c468b86..e229e0b1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,7 +9,7 @@ on: workflow_dispatch: inputs: publish_pypi: - description: "Publish Python wheels to PyPI (tag pushes only)" + description: "Manually build and publish Python wheels to PyPI" required: false default: false type: boolean @@ -615,7 +615,7 @@ jobs: pypi-publish: name: Publish Python wheels to PyPI - needs: [release] + needs: [build-python, build-python-sdist] runs-on: ubuntu-latest if: | (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.PUBLISH_TO_PYPI == 'true') || diff --git a/Cargo.lock b/Cargo.lock index 280542de..e547ad6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -707,7 +707,6 @@ dependencies = [ "fff-search", "git2", "pyo3", - "serde_json", ] [[package]] diff --git a/README.md b/README.md index ba9e4909..9103c16b 100644 --- a/README.md +++ b/README.md @@ -664,6 +664,46 @@ Source: [`crates/fff-c/`](./crates/fff-c/). Stable C ABI. Bind from C/C++, Zig, Go via cgo, Python via ctypes, or anything with C FFI. +
+ +

Python bindings

+
+ +### Install + +```bash +pip install fff-python +``` + +Or build and install from source: + +```bash +cd packages/fff-python +uv sync --all-extras +uv run maturin develop --release +``` + +### Basic usage + +```python +from fff import FileFinder + +with FileFinder("/path/to/project", watch=False) as finder: + finder.wait_for_scan(timeout_ms=5000) + + result = finder.search("main") + for item, score in zip(result.items, result.scores): + print(f"{item.relative_path}: {score.total}") + + hits = finder.grep("class Profile", mode="plain", before_context=1, after_context=1) +``` + +Source: [`packages/fff-python/`](./packages/fff-python/). + +
+ +Native Python bindings built with PyO3. Use them for notebooks, agent scripts, or any Python tool that needs fast file search. + --- ## What is FFF and why use it over ripgrep or fzf? diff --git a/crates/fff-python/Cargo.toml b/crates/fff-python/Cargo.toml index e56b6ce5..9fcedd33 100644 --- a/crates/fff-python/Cargo.toml +++ b/crates/fff-python/Cargo.toml @@ -16,4 +16,3 @@ fff = { package = "fff-search", path = "../fff-core", version = "0.9.4" } fff-query-parser = { path = "../fff-query-parser", version = "0.9.4" } git2 = { workspace = true } pyo3 = { version = "0.24.0", features = ["extension-module", "abi3-py310"] } -serde_json = "1.0" diff --git a/crates/fff-python/src/lib.rs b/crates/fff-python/src/lib.rs index 34ddf0b2..5f57a270 100644 --- a/crates/fff-python/src/lib.rs +++ b/crates/fff-python/src/lib.rs @@ -10,6 +10,7 @@ use fff::{ }; use pyo3::create_exception; use pyo3::prelude::*; +use pyo3::types::PyDict; create_exception!(fff_python, FFFException, pyo3::exceptions::PyException); @@ -17,6 +18,14 @@ fn py_err(e: E) -> PyErr { PyErr::new::(format!("{}", e)) } +fn parse_grep_mode(mode: &str) -> fff::GrepMode { + match mode { + "regex" => fff::GrepMode::Regex, + "fuzzy" => fff::GrepMode::Fuzzy, + _ => fff::GrepMode::PlainText, + } +} + // --------------------------------------------------------------------------- // Result types // --------------------------------------------------------------------------- @@ -126,6 +135,37 @@ impl DirItem { } } +impl MixedFileItem { + fn from_core(item: &fff::FileItem, picker: &FilePicker) -> Self { + Self { + relative_path: item.relative_path(picker), + file_name: item.file_name(picker), + git_status: fff::git::format_git_status(item.git_status).to_string(), + size: item.size, + modified: item.modified, + access_frecency_score: item.access_frecency_score as i64, + modification_frecency_score: item.modification_frecency_score as i64, + total_frecency_score: item.total_frecency_score() as i64, + is_binary: item.is_binary(), + } + } +} + +impl MixedDirItem { + fn from_core(item: &fff::DirItem, picker: &FilePicker) -> Self { + Self { + relative_path: item.relative_path(picker), + dir_name: item.dir_name(picker), + max_access_frecency: item.max_access_frecency() as i64, + } + } +} + +enum MixedItem { + File(MixedFileItem), + Dir(MixedDirItem), +} + #[pyclass] pub struct MixedFileItem { #[pyo3(get)] @@ -320,6 +360,9 @@ pub struct FileFinder { picker: SharedFilePicker, frecency: SharedFrecency, query_tracker: SharedQueryTracker, + cache_budget_max_files: usize, + cache_budget_max_bytes: u64, + cache_budget_max_file_size: u64, } impl Drop for FileFinder { @@ -432,6 +475,9 @@ impl FileFinder { picker: shared_picker, frecency: shared_frecency, query_tracker, + cache_budget_max_files: cache_budget_max_files as usize, + cache_budget_max_bytes, + cache_budget_max_file_size, }) } @@ -439,7 +485,9 @@ impl FileFinder { slf } - fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) {} + fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) { + let _ = self.destroy(); + } fn destroy(&mut self) -> PyResult<()> { if let Ok(mut guard) = self.picker.write() { @@ -461,60 +509,74 @@ impl FileFinder { max_threads=0, page_index=0, page_size=0, - combo_boost_multiplier=0, + combo_boost_score_multiplier=0, min_combo_count=0, ))] fn search( &self, + py: Python<'_>, query: &str, current_file: Option, max_threads: u32, page_index: u32, page_size: u32, - combo_boost_multiplier: i32, + combo_boost_score_multiplier: i32, min_combo_count: u32, ) -> PyResult { - let picker_guard = self.picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - let qt_guard = self.query_tracker.read().map_err(py_err)?; - - let parser = QueryParser::default(); - let parsed = parser.parse(query); - let result = picker.fuzzy_search( - &parsed, - qt_guard.as_ref(), - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier: combo_boost_multiplier, - min_combo_count, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); + let query = query.to_string(); + + let (items, scores, total_matched, total_files) = + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let qt_guard = query_tracker.read().map_err(py_err)?; + + let parser = QueryParser::default(); + let parsed = parser.parse(&query); + let result = picker.fuzzy_search( + &parsed, + qt_guard.as_ref(), + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier, + min_combo_count, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, }, - }, - }, - ); + ); - let items: Vec = result - .items - .iter() - .map(|i| FileItem::from_core(i, picker)) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); + let items: Vec = result + .items + .iter() + .map(|i| FileItem::from_core(i, picker)) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(( + items, + scores, + result.total_matched as u32, + result.total_files as u32, + )) + })?; Ok(SearchResult { items, scores, - total_matched: result.total_matched as u32, - total_files: result.total_files as u32, + total_matched, + total_files, }) } @@ -527,48 +589,62 @@ impl FileFinder { ))] fn glob( &self, + py: Python<'_>, pattern: &str, current_file: Option, max_threads: u32, page_index: u32, page_size: u32, ) -> PyResult { - let picker_guard = self.picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - let result = picker.glob( - pattern, - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier: 0, - min_combo_count: 0, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize + let picker = self.picker.clone(); + let pattern = pattern.to_string(); + + let (items, scores, total_matched, total_files) = + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let result = picker.glob( + &pattern, + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier: 0, + min_combo_count: 0, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, }, - }, - }, - ); + ); - let items: Vec = result - .items - .iter() - .map(|i| FileItem::from_core(i, picker)) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); + let items: Vec = result + .items + .iter() + .map(|i| FileItem::from_core(i, picker)) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(( + items, + scores, + result.total_matched as u32, + result.total_files as u32, + )) + })?; Ok(SearchResult { items, scores, - total_matched: result.total_matched as u32, - total_files: result.total_files as u32, + total_matched, + total_files, }) } @@ -581,50 +657,64 @@ impl FileFinder { ))] fn directory_search( &self, + py: Python<'_>, query: &str, current_file: Option, max_threads: u32, page_index: u32, page_size: u32, ) -> PyResult { - let picker_guard = self.picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - let parser = QueryParser::new(fff_query_parser::DirSearchConfig); - let parsed = parser.parse(query); - let result = picker.fuzzy_search_directories( - &parsed, - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier: 0, - min_combo_count: 0, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize + let picker = self.picker.clone(); + let query = query.to_string(); + + let (items, scores, total_matched, total_dirs) = + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let parser = QueryParser::new(fff_query_parser::DirSearchConfig); + let parsed = parser.parse(&query); + let result = picker.fuzzy_search_directories( + &parsed, + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier: 0, + min_combo_count: 0, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, }, - }, - }, - ); + ); - let items: Vec = result - .items - .iter() - .map(|i| DirItem::from_core(i, picker)) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); + let items: Vec = result + .items + .iter() + .map(|i| DirItem::from_core(i, picker)) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(( + items, + scores, + result.total_matched as u32, + result.total_dirs as u32, + )) + })?; Ok(DirSearchResult { items, scores, - total_matched: result.total_matched as u32, - total_dirs: result.total_dirs as u32, + total_matched, + total_dirs, }) } @@ -635,93 +725,91 @@ impl FileFinder { max_threads=0, page_index=0, page_size=0, - combo_boost_multiplier=0, + combo_boost_score_multiplier=0, min_combo_count=0, ))] fn mixed_search( &self, + py: Python<'_>, query: &str, current_file: Option, max_threads: u32, page_index: u32, page_size: u32, - combo_boost_multiplier: i32, + combo_boost_score_multiplier: i32, min_combo_count: u32, ) -> PyResult { - let picker_guard = self.picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - let qt_guard = self.query_tracker.read().map_err(py_err)?; - - let parser = QueryParser::new(fff_query_parser::MixedSearchConfig); - let parsed = parser.parse(query); - let result = picker.fuzzy_search_mixed( - &parsed, - qt_guard.as_ref(), - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier: combo_boost_multiplier, - min_combo_count, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); + let query = query.to_string(); + + let (items, scores, total_matched, total_files, total_dirs) = + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let qt_guard = query_tracker.read().map_err(py_err)?; + + let parser = QueryParser::new(fff_query_parser::MixedSearchConfig); + let parsed = parser.parse(&query); + let result = picker.fuzzy_search_mixed( + &parsed, + qt_guard.as_ref(), + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier, + min_combo_count, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, }, - }, - }, - ); + ); - Python::with_gil(|py| { - let items: PyResult> = result - .items - .iter() - .map(|item| match item { - fff::MixedItemRef::File(file) => { - let it = FileItem::from_core(file, picker); - Ok(Py::new( - py, - MixedFileItem { - relative_path: it.relative_path, - file_name: it.file_name, - git_status: it.git_status, - size: it.size, - modified: it.modified, - access_frecency_score: it.access_frecency_score, - modification_frecency_score: it.modification_frecency_score, - total_frecency_score: it.total_frecency_score, - is_binary: it.is_binary, - }, - )? - .into_any()) - } - fff::MixedItemRef::Dir(dir) => { - let it = DirItem::from_core(dir, picker); - Ok(Py::new( - py, - MixedDirItem { - relative_path: it.relative_path, - dir_name: it.dir_name, - max_access_frecency: it.max_access_frecency as i64, - }, - )? - .into_any()) - } - }) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); - Ok(MixedSearchResult { - items: items?, - scores, - total_matched: result.total_matched as u32, - total_files: result.total_files as u32, - total_dirs: result.total_dirs as u32, + let items: Vec = result + .items + .iter() + .map(|item| match item { + fff::MixedItemRef::File(file) => { + MixedItem::File(MixedFileItem::from_core(file, picker)) + } + fff::MixedItemRef::Dir(dir) => { + MixedItem::Dir(MixedDirItem::from_core(dir, picker)) + } + }) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(( + items, + scores, + result.total_matched as u32, + result.total_files as u32, + result.total_dirs as u32, + )) + })?; + + let items: PyResult> = items + .into_iter() + .map(|item| match item { + MixedItem::File(file) => Ok(Py::new(py, file)?.into_any()), + MixedItem::Dir(dir) => Ok(Py::new(py, dir)?.into_any()), }) + .collect(); + + Ok(MixedSearchResult { + items: items?, + scores, + total_matched, + total_files, + total_dirs, }) } @@ -741,6 +829,7 @@ impl FileFinder { ))] fn grep( &self, + py: Python<'_>, query: &str, mode: &str, max_file_size: u64, @@ -753,62 +842,80 @@ impl FileFinder { after_context: u32, classify_definitions: bool, ) -> PyResult { - let picker_guard = self.picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - let mode = match mode { - "regex" => fff::GrepMode::Regex, - "fuzzy" => fff::GrepMode::Fuzzy, - _ => fff::GrepMode::PlainText, - }; - - let is_ai = picker.mode().is_ai(); - let parsed = if is_ai { - QueryParser::new(fff_query_parser::AiGrepConfig).parse(query) - } else { - fff::grep::parse_grep_query(query) - }; + let picker = self.picker.clone(); + let query = query.to_string(); + let mode = parse_grep_mode(mode); - let options = GrepSearchOptions { - max_file_size: if max_file_size == 0 { - 10 * 1024 * 1024 - } else { - max_file_size - }, - max_matches_per_file: max_matches_per_file as usize, - smart_case, - file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), - page_limit: if page_limit == 0 { - 50 + let ( + items, + total_matched, + total_files_searched, + total_files, + filtered_file_count, + next_file_offset, + regex_fallback_error, + ) = py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let is_ai = picker.mode().is_ai(); + let parsed = if is_ai { + QueryParser::new(fff_query_parser::AiGrepConfig).parse(&query) } else { - page_limit as usize - }, - mode, - time_budget_ms, - before_context: before_context as usize, - after_context: after_context as usize, - classify_definitions, - trim_whitespace: false, - abort_signal: None, - }; + fff::grep::parse_grep_query(&query) + }; + + let options = GrepSearchOptions { + max_file_size: if max_file_size == 0 { + 10 * 1024 * 1024 + } else { + max_file_size + }, + max_matches_per_file: max_matches_per_file as usize, + smart_case, + file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), + page_limit: if page_limit == 0 { + 50 + } else { + page_limit as usize + }, + mode, + time_budget_ms, + before_context: before_context as usize, + after_context: after_context as usize, + classify_definitions, + trim_whitespace: false, + abort_signal: None, + }; + + let result = picker.grep(&parsed, &options); + let items: Vec = result + .matches + .iter() + .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) + .collect(); - let result = picker.grep(&parsed, &options); - let items: Vec = result - .matches - .iter() - .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) - .collect(); + Ok(( + items, + result.matches.len() as u32, + result.total_files_searched as u32, + result.total_files as u32, + result.filtered_file_count as u32, + result.next_file_offset as u32, + result.regex_fallback_error, + )) + })?; Ok(GrepResult { items, - total_matched: result.matches.len() as u32, - total_files_searched: result.total_files_searched as u32, - total_files: result.total_files as u32, - filtered_file_count: result.filtered_file_count as u32, - next_file_offset: result.next_file_offset as u32, - regex_fallback_error: result.regex_fallback_error, + total_matched, + total_files_searched, + total_files, + filtered_file_count, + next_file_offset, + regex_fallback_error, }) } @@ -816,6 +923,7 @@ impl FileFinder { #[pyo3(signature = ( patterns, constraints=None, + mode="plain", max_file_size=0, max_matches_per_file=0, smart_case=true, @@ -828,8 +936,10 @@ impl FileFinder { ))] fn multi_grep( &self, + py: Python<'_>, patterns: Vec, constraints: Option, + mode: &str, max_file_size: u64, max_matches_per_file: u32, smart_case: bool, @@ -840,67 +950,90 @@ impl FileFinder { after_context: u32, classify_definitions: bool, ) -> PyResult { - let picker_guard = self.picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - if patterns.is_empty() || patterns.iter().all(|p| p.is_empty()) { - return Err(py_err("patterns must not be empty")); - } - let patterns: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect(); + let picker = self.picker.clone(); + let mode = parse_grep_mode(mode); - let is_ai = picker.mode().is_ai(); - let parsed_constraints = constraints.as_ref().map(|c| { - if is_ai { - QueryParser::new(fff_query_parser::AiGrepConfig).parse(c) - } else { - fff::grep::parse_grep_query(c) + let ( + items, + total_matched, + total_files_searched, + total_files, + filtered_file_count, + next_file_offset, + regex_fallback_error, + ) = py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + if patterns.is_empty() || patterns.iter().all(|p| p.is_empty()) { + return Err(py_err("patterns must not be empty")); } - }); - let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints { - Some(q) => &q.constraints, - None => &[], - }; - - let options = GrepSearchOptions { - max_file_size: if max_file_size == 0 { - 10 * 1024 * 1024 - } else { - max_file_size - }, - max_matches_per_file: max_matches_per_file as usize, - smart_case, - file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), - page_limit: if page_limit == 0 { - 50 - } else { - page_limit as usize - }, - mode: fff::GrepMode::PlainText, - time_budget_ms, - before_context: before_context as usize, - after_context: after_context as usize, - classify_definitions, - trim_whitespace: false, - abort_signal: None, - }; + let pattern_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect(); + + let is_ai = picker.mode().is_ai(); + let parsed_constraints = constraints.as_ref().map(|c| { + if is_ai { + QueryParser::new(fff_query_parser::AiGrepConfig).parse(c) + } else { + fff::grep::parse_grep_query(c) + } + }); + let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints { + Some(q) => &q.constraints, + None => &[], + }; + + let options = GrepSearchOptions { + max_file_size: if max_file_size == 0 { + 10 * 1024 * 1024 + } else { + max_file_size + }, + max_matches_per_file: max_matches_per_file as usize, + smart_case, + file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), + page_limit: if page_limit == 0 { + 50 + } else { + page_limit as usize + }, + mode, + time_budget_ms, + before_context: before_context as usize, + after_context: after_context as usize, + classify_definitions, + trim_whitespace: false, + abort_signal: None, + }; + + let result = picker.multi_grep(&pattern_refs, constraint_refs, &options); + let items: Vec = result + .matches + .iter() + .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) + .collect(); - let result = picker.multi_grep(&patterns, constraint_refs, &options); - let items: Vec = result - .matches - .iter() - .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) - .collect(); + Ok(( + items, + result.matches.len() as u32, + result.total_files_searched as u32, + result.total_files as u32, + result.filtered_file_count as u32, + result.next_file_offset as u32, + result.regex_fallback_error, + )) + })?; Ok(GrepResult { items, - total_matched: result.matches.len() as u32, - total_files_searched: result.total_files_searched as u32, - total_files: result.total_files as u32, - filtered_file_count: result.filtered_file_count as u32, - next_file_offset: result.next_file_offset as u32, - regex_fallback_error: result.regex_fallback_error, + total_matched, + total_files_searched, + total_files, + filtered_file_count, + next_file_offset, + regex_fallback_error, }) } @@ -972,7 +1105,11 @@ impl FileFinder { enable_content_indexing: content_indexing, watch, mode, - cache_budget: None, + cache_budget: fff::ContentCacheBudget::from_overrides( + self.cache_budget_max_files, + self.cache_budget_max_bytes, + self.cache_budget_max_file_size, + ), follow_symlinks: false, enable_fs_root_scanning: fs_root, enable_home_dir_scanning: home_dir, @@ -1032,106 +1169,67 @@ impl FileFinder { } #[pyo3(signature = (test_path=None))] - fn health_check(&self, test_path: Option) -> PyResult { + fn health_check(&self, py: Python<'_>, test_path: Option) -> PyResult> { let test_path = test_path .map(PathBuf::from) .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - let mut health = serde_json::Map::new(); - health.insert( - "version".to_string(), - serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()), - ); + let dict = PyDict::new(py); + dict.set_item("version", env!("CARGO_PKG_VERSION"))?; - let mut git_info = serde_json::Map::new(); + let git_info = PyDict::new(py); let git_version = git2::Version::get(); let (major, minor, rev) = git_version.libgit2_version(); - git_info.insert( - "libgit2_version".to_string(), - serde_json::Value::String(format!("{}.{}.{}", major, minor, rev)), - ); + git_info.set_item("libgit2_version", format!("{}.{}.{}", major, minor, rev))?; match git2::Repository::discover(&test_path) { Ok(repo) => { - git_info.insert("available".to_string(), serde_json::Value::Bool(true)); - git_info.insert( - "repository_found".to_string(), - serde_json::Value::Bool(true), - ); + git_info.set_item("available", true)?; + git_info.set_item("repository_found", true)?; if let Some(workdir) = repo.workdir() { - git_info.insert( - "workdir".to_string(), - serde_json::Value::String(workdir.to_string_lossy().to_string()), - ); + git_info.set_item("workdir", workdir.to_string_lossy().to_string())?; } } Err(e) => { - git_info.insert("available".to_string(), serde_json::Value::Bool(true)); - git_info.insert( - "repository_found".to_string(), - serde_json::Value::Bool(false), - ); - git_info.insert( - "error".to_string(), - serde_json::Value::String(e.message().to_string()), - ); + git_info.set_item("available", true)?; + git_info.set_item("repository_found", false)?; + git_info.set_item("error", e.message().to_string())?; } } - health.insert("git".to_string(), serde_json::Value::Object(git_info)); + dict.set_item("git", git_info)?; - let mut picker_info = serde_json::Map::new(); + let picker_info = PyDict::new(py); { let guard = self.picker.read().map_err(py_err)?; if let Some(ref picker) = *guard { - picker_info.insert("initialized".to_string(), serde_json::Value::Bool(true)); - picker_info.insert( - "base_path".to_string(), - serde_json::Value::String(picker.base_path().to_string_lossy().to_string()), - ); - picker_info.insert( - "is_scanning".to_string(), - serde_json::Value::Bool(picker.is_scan_active()), - ); + picker_info.set_item("initialized", true)?; + picker_info.set_item( + "base_path", + picker.base_path().to_string_lossy().to_string(), + )?; + picker_info.set_item("is_scanning", picker.is_scan_active())?; let progress = picker.get_scan_progress(); - picker_info.insert( - "indexed_files".to_string(), - serde_json::Value::Number(progress.scanned_files_count.into()), - ); + picker_info.set_item("indexed_files", progress.scanned_files_count)?; } else { - picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false)); + picker_info.set_item("initialized", false)?; } } - health.insert( - "file_picker".to_string(), - serde_json::Value::Object(picker_info), - ); + dict.set_item("file_picker", picker_info)?; - let mut frecency_info = serde_json::Map::new(); + let frecency_info = PyDict::new(py); { let guard = self.frecency.read().map_err(py_err)?; - frecency_info.insert( - "initialized".to_string(), - serde_json::Value::Bool(guard.is_some()), - ); + frecency_info.set_item("initialized", guard.is_some())?; } - health.insert( - "frecency".to_string(), - serde_json::Value::Object(frecency_info), - ); + dict.set_item("frecency", frecency_info)?; - let mut query_info = serde_json::Map::new(); + let query_info = PyDict::new(py); { let guard = self.query_tracker.read().map_err(py_err)?; - query_info.insert( - "initialized".to_string(), - serde_json::Value::Bool(guard.is_some()), - ); + query_info.set_item("initialized", guard.is_some())?; } - health.insert( - "query_tracker".to_string(), - serde_json::Value::Object(query_info), - ); + dict.set_item("query_tracker", query_info)?; - serde_json::to_string(&health).map_err(|e| py_err(format!("JSON error: {}", e))) + Ok(dict.unbind()) } } diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py index 3eeaf648..caade9cd 100644 --- a/packages/fff-python/tests/test_finder.py +++ b/packages/fff-python/tests/test_finder.py @@ -79,6 +79,6 @@ def test_multi_grep(sample_dir: str) -> None: def test_health_check(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan(timeout_ms=5000) - health = json.loads(finder.health_check()) + health = finder.health_check() assert "version" in health assert health["file_picker"]["initialized"] is True From 15288542fd973e3aac823cc6ea0ff2a481d1a20c Mon Sep 17 00:00:00 2001 From: 4fu Date: Tue, 16 Jun 2026 13:15:47 +0800 Subject: [PATCH 04/15] fix(python): sync release versions and expand tests --- crates/fff-python/src/lib.rs | 8 + packages/fff-python/src/fff/__init__.py | 2 + packages/fff-python/tests/test_finder.py | 212 +++++++++++++++++++---- scripts/release.sh | 46 +++++ 4 files changed, 234 insertions(+), 34 deletions(-) diff --git a/crates/fff-python/src/lib.rs b/crates/fff-python/src/lib.rs index 5f57a270..99767e62 100644 --- a/crates/fff-python/src/lib.rs +++ b/crates/fff-python/src/lib.rs @@ -351,6 +351,14 @@ pub struct GrepCursor { pub offset: u32, } +#[pymethods] +impl GrepCursor { + #[new] + fn new(offset: u32) -> Self { + Self { offset } + } +} + // --------------------------------------------------------------------------- // FileFinder // --------------------------------------------------------------------------- diff --git a/packages/fff-python/src/fff/__init__.py b/packages/fff-python/src/fff/__init__.py index a51ee768..9a77f180 100644 --- a/packages/fff-python/src/fff/__init__.py +++ b/packages/fff-python/src/fff/__init__.py @@ -10,6 +10,7 @@ FileItem, GrepMatch, GrepResult, + GrepCursor, MatchRange, MixedDirItem, MixedFileItem, @@ -35,6 +36,7 @@ "MatchRange", "GrepMatch", "GrepResult", + "GrepCursor", "ScanProgress", "__version__", ] diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py index caade9cd..8d4de136 100644 --- a/packages/fff-python/tests/test_finder.py +++ b/packages/fff-python/tests/test_finder.py @@ -2,14 +2,18 @@ from __future__ import annotations -import json -import os +import importlib.metadata as metadata import tempfile from pathlib import Path import pytest -from fff import FileFinder +import fff +from fff import FFFException, FileFinder, GrepCursor, MixedDirItem, MixedFileItem + + +def rel(path: str) -> str: + return path.replace("\\", "/") @pytest.fixture @@ -17,68 +21,208 @@ def sample_dir() -> str: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) (root / "src").mkdir() - (root / "src" / "main.py").write_text("def main():\n pass\n") - (root / "src" / "utils.py").write_text("def helper():\n pass\n") + (root / "docs").mkdir() + (root / "src" / "main.py").write_text( + "from utils import helper\n\n" + "def main():\n" + " value = helper()\n" + " return value\n" + ) + (root / "src" / "utils.py").write_text( + "def helper():\n" + " return 'alpha'\n" + ) + (root / "src" / "profile.ts").write_text( + "class Profile {}\n" + "function renderProfile() { return new Profile(); }\n" + ) + (root / "docs" / "guide.txt").write_text( + "alpha line before\n" + "needle target\n" + "omega line after\n" + ) (root / "README.md").write_text("# Sample project\n") yield str(root) -def test_create_and_destroy(sample_dir: str) -> None: +def test_imports_and_package_version() -> None: + assert fff.__version__ == metadata.version("fff-python") + assert GrepCursor(12).offset == 12 + assert "GrepCursor" in fff.__all__ + + +def test_create_destroy_and_context_manager(sample_dir: str) -> None: finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False) assert finder.wait_for_scan(timeout_ms=5000) assert finder.get_base_path() is not None finder.destroy() + with pytest.raises(FFFException, match="File picker not initialized"): + finder.search("main") + + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as ctx_finder: + assert ctx_finder.wait_for_scan(timeout_ms=5000) -def test_file_search(sample_dir: str) -> None: + with pytest.raises(FFFException, match="File picker not initialized"): + ctx_finder.search("main") + + +def test_file_search_scores_and_pagination(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan(timeout_ms=5000) - result = finder.search("main") + result = finder.search("main", page_size=1) assert result.total_matched >= 1 - paths = {item.relative_path for item in result.items} - assert any("main.py" in p for p in paths) + assert len(result.items) == 1 + assert any("main.py" in rel(item.relative_path) for item in result.items) + score = result.scores[0] + assert isinstance(score.total, int) + assert isinstance(score.exact_match, bool) + assert isinstance(score.match_type, str) -def test_glob(sample_dir: str) -> None: - with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: - assert finder.wait_for_scan(timeout_ms=5000) - result = finder.glob("*.py") - assert result.total_matched == 2 + second_page = finder.search("", page_index=1, page_size=1) + assert len(second_page.items) == 1 -def test_directory_search(sample_dir: str) -> None: +def test_glob_variants(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan(timeout_ms=5000) - result = finder.directory_search("src") - assert result.total_matched >= 1 - assert any("src" in item.relative_path for item in result.items) + + py_files = finder.glob("*.py") + assert {Path(rel(item.relative_path)).name for item in py_files.items} == { + "main.py", + "utils.py", + } + + src_files = finder.glob("src/*.py") + assert src_files.total_matched == 2 + + md_files = finder.glob("*.md") + assert md_files.total_matched == 1 + assert rel(md_files.items[0].relative_path) == "README.md" -def test_mixed_search(sample_dir: str) -> None: +def test_directory_and_mixed_search(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan(timeout_ms=5000) - result = finder.mixed_search("main") - assert result.total_matched >= 1 + dirs = finder.directory_search("src") + assert dirs.total_matched >= 1 + assert any(rel(item.relative_path).startswith("src") for item in dirs.items) -def test_grep(sample_dir: str) -> None: - with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + mixed = finder.mixed_search("src", page_size=10) + assert mixed.total_matched >= 3 + assert any(isinstance(item, MixedDirItem) for item in mixed.items) + assert any(isinstance(item, MixedFileItem) for item in mixed.items) + + +def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: assert finder.wait_for_scan(timeout_ms=5000) - result = finder.grep("def main") - assert result.total_matched >= 1 - assert any("main.py" in m.relative_path for m in result.items) + plain = finder.grep("needle", before_context=1, after_context=1) + assert plain.total_matched == 1 + match = plain.items[0] + assert rel(match.relative_path) == "docs/guide.txt" + assert match.line_content == "needle target" + assert match.context_before == ["alpha line before"] + assert match.context_after == ["omega line after"] + assert [(r.start, r.end) for r in match.match_ranges] == [(0, 6)] + + regex = finder.grep(r"def \w+", mode="regex") + assert regex.total_matched == 2 + assert {Path(rel(m.relative_path)).name for m in regex.items} == { + "main.py", + "utils.py", + } + + fuzzy = finder.grep("df mn", mode="fuzzy") + assert fuzzy.total_matched >= 1 + assert any(rel(m.relative_path) == "src/main.py" for m in fuzzy.items) + assert any(m.fuzzy_score is not None for m in fuzzy.items) + + invalid = finder.grep("[", mode="regex") + assert invalid.regex_fallback_error is not None + + +def test_grep_cursor_paginates_by_file(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: + assert finder.wait_for_scan(timeout_ms=5000) -def test_multi_grep(sample_dir: str) -> None: - with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + first = finder.grep("def", page_limit=1) + assert first.total_matched >= 1 + assert first.next_file_offset > 0 + + second = finder.grep("def", cursor=GrepCursor(first.next_file_offset), page_limit=1) + assert second.total_matched >= 1 + + first_paths = {rel(m.relative_path) for m in first.items} + second_paths = {rel(m.relative_path) for m in second.items} + assert first_paths.isdisjoint(second_paths) + + +def test_multi_grep_and_error_handling(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: assert finder.wait_for_scan(timeout_ms=5000) + result = finder.multi_grep(["def main", "def helper"]) - assert result.total_matched >= 2 + assert result.total_matched == 2 + assert {Path(rel(m.relative_path)).name for m in result.items} == { + "main.py", + "utils.py", + } + with pytest.raises(FFFException, match="patterns must not be empty"): + finder.multi_grep([]) -def test_health_check(sample_dir: str) -> None: - with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + +def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None: + history_db = tmp_path / "history" + selected_file = str(Path(sample_dir) / "src" / "main.py") + + with FileFinder( + sample_dir, + history_db_path=str(history_db), + watch=False, + enable_content_indexing=False, + ) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + assert finder.track_query("main", selected_file) + assert finder.get_historical_query(0) == "main" + + with FileFinder( + sample_dir, + history_db_path=str(history_db), + watch=False, + enable_content_indexing=False, + ) as finder: assert finder.wait_for_scan(timeout_ms=5000) - health = finder.health_check() - assert "version" in health + assert finder.get_historical_query(0) == "main" + + +def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None: + other = tmp_path / "other-project" + other.mkdir() + (other / "other.py").write_text("def other():\n return 42\n") + + frecency_db = tmp_path / "frecency" + history_db = tmp_path / "history" + with FileFinder( + sample_dir, + frecency_db_path=str(frecency_db), + history_db_path=str(history_db), + watch=False, + enable_content_indexing=False, + ) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + + health = finder.health_check(sample_dir) assert health["file_picker"]["initialized"] is True + assert health["frecency"]["initialized"] is True + assert health["query_tracker"]["initialized"] is True + + finder.reindex(str(other)) + assert finder.wait_for_scan(timeout_ms=5000) + result = finder.search("other") + assert result.total_matched == 1 + assert rel(result.items[0].relative_path) == "other.py" diff --git a/scripts/release.sh b/scripts/release.sh index 59f61a5f..5ca499a4 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -30,6 +30,52 @@ cargo install cargo-edit --force --locked echo "→ Updating Cargo.toml versions to $VERSION" cargo set-version "$VERSION" +echo "→ Updating Python package version to $VERSION" +python - "$VERSION" <<'PY' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +version = sys.argv[1] +root = Path.cwd() + + +def replace_once(path: Path, pattern: str, replacement: str) -> None: + text = path.read_text(encoding="utf-8") + text, count = re.subn(pattern, replacement, text, count=1) + if count != 1: + raise SystemExit(f"failed to update version in {path}") + path.write_text(text, encoding="utf-8") + + +replace_once( + root / "packages/fff-python/pyproject.toml", + r'(?m)^version = "[^"]+"$', + f'version = "{version}"', +) +replace_once( + root / "packages/fff-python/src/fff/__init__.py", + r'(?m)^__version__ = "[^"]+"$', + f'__version__ = "{version}"', +) + +lock_path = root / "packages/fff-python/uv.lock" +if lock_path.exists(): + text = lock_path.read_text(encoding="utf-8") + marker = '[[package]]\nname = "fff-python"\nversion = "' + start = text.find(marker) + if start == -1: + raise SystemExit(f"failed to find fff-python package in {lock_path}") + version_start = start + len(marker) + version_end = text.find('"', version_start) + if version_end == -1: + raise SystemExit(f"failed to find fff-python version end in {lock_path}") + text = text[:version_start] + version + text[version_end:] + lock_path.write_text(text, encoding="utf-8") +PY + git add -A git commit -m "chore: release $VERSION" From 0143702531c392186761cd4c2609f1edf65d0a50 Mon Sep 17 00:00:00 2001 From: 4fu Date: Tue, 16 Jun 2026 13:34:26 +0800 Subject: [PATCH 05/15] refactor(python): split lib.rs and improve Pythonic API - Split crates/fff-python/src/lib.rs into modules: - types.rs: all pyclass result types - finder.rs: FileFinder implementation - conversions.rs: From/core conversions - Make API more Pythonic: - FileFinder now accepts pathlib.Path / os.PathLike for base_path and reindex - Add close() alias for destroy() - grep/multi_grep now raise FFFException for invalid modes - GrepResult gains has_more property and next_cursor() method - Add type stubs: - packages/fff-python/src/fff/__init__.pyi - packages/fff-python/src/fff/py.typed - Add __repr__ implementations for all exposed pyclasses - Expand Python tests for pathlib, close(), reprs, invalid mode, and cursor pagination --- crates/fff-python/src/conversions.rs | 107 ++ crates/fff-python/src/finder.rs | 901 +++++++++++++++ crates/fff-python/src/lib.rs | 1274 +--------------------- crates/fff-python/src/types.rs | 376 +++++++ packages/fff-python/src/fff/__init__.pyi | 239 ++++ packages/fff-python/src/fff/py.typed | 0 packages/fff-python/tests/test_finder.py | 64 +- 7 files changed, 1712 insertions(+), 1249 deletions(-) create mode 100644 crates/fff-python/src/conversions.rs create mode 100644 crates/fff-python/src/finder.rs create mode 100644 crates/fff-python/src/types.rs create mode 100644 packages/fff-python/src/fff/__init__.pyi create mode 100644 packages/fff-python/src/fff/py.typed diff --git a/crates/fff-python/src/conversions.rs b/crates/fff-python/src/conversions.rs new file mode 100644 index 00000000..ca21611b --- /dev/null +++ b/crates/fff-python/src/conversions.rs @@ -0,0 +1,107 @@ +use fff::file_picker::FilePicker; + +use crate::types::{DirItem, FileItem, GrepMatch, MatchRange, MixedDirItem, MixedFileItem, Score}; + +pub enum MixedItem { + File(MixedFileItem), + Dir(MixedDirItem), +} + +impl From<&fff::Score> for Score { + fn from(s: &fff::Score) -> Self { + Self { + total: s.total, + base_score: s.base_score, + filename_bonus: s.filename_bonus, + special_filename_bonus: s.special_filename_bonus, + frecency_boost: s.frecency_boost, + distance_penalty: s.distance_penalty, + current_file_penalty: s.current_file_penalty, + combo_match_boost: s.combo_match_boost, + path_alignment_bonus: s.path_alignment_bonus, + exact_match: s.exact_match, + match_type: s.match_type.to_string(), + } + } +} + +impl FileItem { + pub fn from_core(item: &fff::FileItem, picker: &FilePicker) -> Self { + Self { + relative_path: item.relative_path(picker), + file_name: item.file_name(picker), + git_status: fff::git::format_git_status(item.git_status).to_string(), + size: item.size, + modified: item.modified, + access_frecency_score: item.access_frecency_score as i64, + modification_frecency_score: item.modification_frecency_score as i64, + total_frecency_score: item.total_frecency_score() as i64, + is_binary: item.is_binary(), + } + } +} + +impl DirItem { + pub fn from_core(item: &fff::DirItem, picker: &FilePicker) -> Self { + Self { + relative_path: item.relative_path(picker), + dir_name: item.dir_name(picker), + max_access_frecency: item.max_access_frecency(), + } + } +} + +impl MixedFileItem { + pub fn from_core(item: &fff::FileItem, picker: &FilePicker) -> Self { + Self { + relative_path: item.relative_path(picker), + file_name: item.file_name(picker), + git_status: fff::git::format_git_status(item.git_status).to_string(), + size: item.size, + modified: item.modified, + access_frecency_score: item.access_frecency_score as i64, + modification_frecency_score: item.modification_frecency_score as i64, + total_frecency_score: item.total_frecency_score() as i64, + is_binary: item.is_binary(), + } + } +} + +impl MixedDirItem { + pub fn from_core(item: &fff::DirItem, picker: &FilePicker) -> Self { + Self { + relative_path: item.relative_path(picker), + dir_name: item.dir_name(picker), + max_access_frecency: item.max_access_frecency() as i64, + } + } +} + +impl GrepMatch { + pub fn from_core(m: &fff::GrepMatch, file: &fff::FileItem, picker: &FilePicker) -> Self { + Self { + relative_path: file.relative_path(picker), + file_name: file.file_name(picker), + git_status: fff::git::format_git_status(file.git_status).to_string(), + line_content: m.line_content.clone(), + match_ranges: m + .match_byte_offsets + .iter() + .map(|&(s, e)| MatchRange { start: s, end: e }) + .collect(), + context_before: m.context_before.clone(), + context_after: m.context_after.clone(), + size: file.size, + modified: file.modified, + total_frecency_score: file.total_frecency_score() as i64, + access_frecency_score: file.access_frecency_score as i64, + modification_frecency_score: file.modification_frecency_score as i64, + line_number: m.line_number, + byte_offset: m.byte_offset, + col: m.col as u32, + fuzzy_score: m.fuzzy_score, + is_definition: m.is_definition, + is_binary: file.is_binary(), + } + } +} diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs new file mode 100644 index 00000000..240787f9 --- /dev/null +++ b/crates/fff-python/src/finder.rs @@ -0,0 +1,901 @@ +use std::path::PathBuf; +use std::time::Duration; + +use fff::file_picker::FilePicker; +use fff::frecency::FrecencyTracker; +use fff::query_tracker::QueryTracker; +use fff::{ + FFFMode, FilePickerOptions, FuzzySearchOptions, GrepSearchOptions, PaginationArgs, QueryParser, + SharedFilePicker, SharedFrecency, SharedQueryTracker, +}; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use crate::conversions::MixedItem; +use crate::types::{ + DirItem, DirSearchResult, FileItem, GrepCursor, GrepMatch, GrepResult, MixedDirItem, + MixedFileItem, MixedSearchResult, ScanProgress, Score, SearchResult, +}; +use crate::{parse_grep_mode, py_err}; + +#[pyclass] +pub struct FileFinder { + picker: SharedFilePicker, + frecency: SharedFrecency, + query_tracker: SharedQueryTracker, + cache_budget_max_files: usize, + cache_budget_max_bytes: u64, + cache_budget_max_file_size: u64, +} + +impl Drop for FileFinder { + fn drop(&mut self) { + if let Ok(mut guard) = self.picker.write() { + guard.take(); + } + if let Ok(mut guard) = self.frecency.write() { + *guard = None; + } + if let Ok(mut guard) = self.query_tracker.write() { + *guard = None; + } + } +} + +#[pymethods] +impl FileFinder { + #[new] + #[pyo3(signature = ( + base_path, + frecency_db_path=None, + history_db_path=None, + enable_mmap_cache=true, + enable_content_indexing=true, + watch=true, + ai_mode=false, + log_file_path=None, + log_level=None, + cache_budget_max_files=0, + cache_budget_max_bytes=0, + cache_budget_max_file_size=0, + enable_fs_root_scanning=false, + enable_home_dir_scanning=false, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + base_path: PathBuf, + frecency_db_path: Option, + history_db_path: Option, + enable_mmap_cache: bool, + enable_content_indexing: bool, + watch: bool, + ai_mode: bool, + log_file_path: Option, + log_level: Option, + cache_budget_max_files: u64, + cache_budget_max_bytes: u64, + cache_budget_max_file_size: u64, + enable_fs_root_scanning: bool, + enable_home_dir_scanning: bool, + ) -> PyResult { + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::default(); + let query_tracker = SharedQueryTracker::default(); + + if let Some(path) = frecency_db_path { + let parent = PathBuf::from(&path).parent().map(PathBuf::from); + if let Some(p) = parent { + let _ = std::fs::create_dir_all(p); + } + let tracker = FrecencyTracker::open(&path).map_err(py_err)?; + shared_frecency.init(tracker).map_err(py_err)?; + } + + if let Some(path) = history_db_path { + let parent = PathBuf::from(&path).parent().map(PathBuf::from); + if let Some(p) = parent { + let _ = std::fs::create_dir_all(p); + } + let tracker = QueryTracker::open(&path).map_err(py_err)?; + query_tracker.init(tracker).map_err(py_err)?; + } + + if let Some(path) = log_file_path { + let level = log_level.as_deref(); + fff::log::init_tracing(&path, level, None).map_err(py_err)?; + } + + let mode = if ai_mode { + FFFMode::Ai + } else { + FFFMode::Neovim + }; + + let cache_budget = fff::ContentCacheBudget::from_overrides( + cache_budget_max_files as usize, + cache_budget_max_bytes, + cache_budget_max_file_size, + ); + + FilePicker::new_with_shared_state( + shared_picker.clone(), + shared_frecency.clone(), + FilePickerOptions { + base_path: base_path.to_string_lossy().to_string(), + enable_mmap_cache, + enable_content_indexing, + watch, + mode, + cache_budget, + follow_symlinks: false, + enable_fs_root_scanning, + enable_home_dir_scanning, + }, + ) + .map_err(py_err)?; + + Ok(Self { + picker: shared_picker, + frecency: shared_frecency, + query_tracker, + cache_budget_max_files: cache_budget_max_files as usize, + cache_budget_max_bytes, + cache_budget_max_file_size, + }) + } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) { + let _ = self.destroy(); + } + + fn destroy(&mut self) -> PyResult<()> { + if let Ok(mut guard) = self.picker.write() { + *guard = None; + } + if let Ok(mut guard) = self.frecency.write() { + *guard = None; + } + if let Ok(mut guard) = self.query_tracker.write() { + *guard = None; + } + Ok(()) + } + + fn close(&mut self) -> PyResult<()> { + self.destroy() + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + query, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + combo_boost_score_multiplier=0, + min_combo_count=0, + ))] + fn search( + &self, + py: Python<'_>, + query: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + combo_boost_score_multiplier: i32, + min_combo_count: u32, + ) -> PyResult { + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); + let query = query.to_string(); + + let (items, scores, total_matched, total_files) = + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let qt_guard = query_tracker.read().map_err(py_err)?; + + let parser = QueryParser::default(); + let parsed = parser.parse(&query); + let result = picker.fuzzy_search( + &parsed, + qt_guard.as_ref(), + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier, + min_combo_count, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, + }, + ); + + let items: Vec = result + .items + .iter() + .map(|i| FileItem::from_core(i, picker)) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(( + items, + scores, + result.total_matched as u32, + result.total_files as u32, + )) + })?; + + Ok(SearchResult { + items, + scores, + total_matched, + total_files, + }) + } + + #[pyo3(signature = ( + pattern, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + ))] + fn glob( + &self, + py: Python<'_>, + pattern: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + ) -> PyResult { + let picker = self.picker.clone(); + let pattern = pattern.to_string(); + + let (items, scores, total_matched, total_files) = + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let result = picker.glob( + &pattern, + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier: 0, + min_combo_count: 0, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, + }, + ); + + let items: Vec = result + .items + .iter() + .map(|i| FileItem::from_core(i, picker)) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(( + items, + scores, + result.total_matched as u32, + result.total_files as u32, + )) + })?; + + Ok(SearchResult { + items, + scores, + total_matched, + total_files, + }) + } + + #[pyo3(signature = ( + query, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + ))] + fn directory_search( + &self, + py: Python<'_>, + query: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + ) -> PyResult { + let picker = self.picker.clone(); + let query = query.to_string(); + + let (items, scores, total_matched, total_dirs) = + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let parser = QueryParser::new(fff_query_parser::DirSearchConfig); + let parsed = parser.parse(&query); + let result = picker.fuzzy_search_directories( + &parsed, + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier: 0, + min_combo_count: 0, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, + }, + ); + + let items: Vec = result + .items + .iter() + .map(|i| DirItem::from_core(i, picker)) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(( + items, + scores, + result.total_matched as u32, + result.total_dirs as u32, + )) + })?; + + Ok(DirSearchResult { + items, + scores, + total_matched, + total_dirs, + }) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + query, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + combo_boost_score_multiplier=0, + min_combo_count=0, + ))] + fn mixed_search( + &self, + py: Python<'_>, + query: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + combo_boost_score_multiplier: i32, + min_combo_count: u32, + ) -> PyResult { + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); + let query = query.to_string(); + + let (items, scores, total_matched, total_files, total_dirs) = + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let qt_guard = query_tracker.read().map_err(py_err)?; + + let parser = QueryParser::new(fff_query_parser::MixedSearchConfig); + let parsed = parser.parse(&query); + let result = picker.fuzzy_search_mixed( + &parsed, + qt_guard.as_ref(), + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file: current_file.as_deref(), + project_path: Some(picker.base_path()), + combo_boost_score_multiplier, + min_combo_count, + pagination: PaginationArgs { + offset: page_index as usize, + limit: if page_size == 0 { + 100 + } else { + page_size as usize + }, + }, + }, + ); + + let items: Vec = result + .items + .iter() + .map(|item| match item { + fff::MixedItemRef::File(file) => { + MixedItem::File(MixedFileItem::from_core(file, picker)) + } + fff::MixedItemRef::Dir(dir) => { + MixedItem::Dir(MixedDirItem::from_core(dir, picker)) + } + }) + .collect(); + let scores: Vec = result.scores.iter().map(Score::from).collect(); + + Ok(( + items, + scores, + result.total_matched as u32, + result.total_files as u32, + result.total_dirs as u32, + )) + })?; + + let items: PyResult> = items + .into_iter() + .map(|item| match item { + MixedItem::File(file) => Ok(Py::new(py, file)?.into_any()), + MixedItem::Dir(dir) => Ok(Py::new(py, dir)?.into_any()), + }) + .collect(); + + Ok(MixedSearchResult { + items: items?, + scores, + total_matched, + total_files, + total_dirs, + }) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + query, + mode="plain", + max_file_size=0, + max_matches_per_file=0, + smart_case=true, + cursor=None, + page_limit=0, + time_budget_ms=0, + before_context=0, + after_context=0, + classify_definitions=false, + ))] + fn grep( + &self, + py: Python<'_>, + query: &str, + mode: &str, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + cursor: Option<&GrepCursor>, + page_limit: u32, + time_budget_ms: u64, + before_context: u32, + after_context: u32, + classify_definitions: bool, + ) -> PyResult { + let picker = self.picker.clone(); + let query = query.to_string(); + let mode = parse_grep_mode(mode)?; + + let ( + items, + total_matched, + total_files_searched, + total_files, + filtered_file_count, + next_file_offset, + regex_fallback_error, + ) = py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let is_ai = picker.mode().is_ai(); + let parsed = if is_ai { + QueryParser::new(fff_query_parser::AiGrepConfig).parse(&query) + } else { + fff::grep::parse_grep_query(&query) + }; + + let options = GrepSearchOptions { + max_file_size: if max_file_size == 0 { + 10 * 1024 * 1024 + } else { + max_file_size + }, + max_matches_per_file: max_matches_per_file as usize, + smart_case, + file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), + page_limit: if page_limit == 0 { + 50 + } else { + page_limit as usize + }, + mode, + time_budget_ms, + before_context: before_context as usize, + after_context: after_context as usize, + classify_definitions, + trim_whitespace: false, + abort_signal: None, + }; + + let result = picker.grep(&parsed, &options); + let items: Vec = result + .matches + .iter() + .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) + .collect(); + + Ok(( + items, + result.matches.len() as u32, + result.total_files_searched as u32, + result.total_files as u32, + result.filtered_file_count as u32, + result.next_file_offset as u32, + result.regex_fallback_error, + )) + })?; + + Ok(GrepResult { + items, + total_matched, + total_files_searched, + total_files, + filtered_file_count, + next_file_offset, + regex_fallback_error, + }) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + patterns, + constraints=None, + mode="plain", + max_file_size=0, + max_matches_per_file=0, + smart_case=true, + cursor=None, + page_limit=0, + time_budget_ms=0, + before_context=0, + after_context=0, + classify_definitions=false, + ))] + fn multi_grep( + &self, + py: Python<'_>, + patterns: Vec, + constraints: Option, + mode: &str, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + cursor: Option<&GrepCursor>, + page_limit: u32, + time_budget_ms: u64, + before_context: u32, + after_context: u32, + classify_definitions: bool, + ) -> PyResult { + let picker = self.picker.clone(); + let mode = parse_grep_mode(mode)?; + + let ( + items, + total_matched, + total_files_searched, + total_files, + filtered_file_count, + next_file_offset, + regex_fallback_error, + ) = py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + if patterns.is_empty() || patterns.iter().all(|p| p.is_empty()) { + return Err(py_err("patterns must not be empty")); + } + let pattern_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect(); + + let is_ai = picker.mode().is_ai(); + let parsed_constraints = constraints.as_ref().map(|c| { + if is_ai { + QueryParser::new(fff_query_parser::AiGrepConfig).parse(c) + } else { + fff::grep::parse_grep_query(c) + } + }); + let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints { + Some(q) => &q.constraints, + None => &[], + }; + + let options = GrepSearchOptions { + max_file_size: if max_file_size == 0 { + 10 * 1024 * 1024 + } else { + max_file_size + }, + max_matches_per_file: max_matches_per_file as usize, + smart_case, + file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), + page_limit: if page_limit == 0 { + 50 + } else { + page_limit as usize + }, + mode, + time_budget_ms, + before_context: before_context as usize, + after_context: after_context as usize, + classify_definitions, + trim_whitespace: false, + abort_signal: None, + }; + + let result = picker.multi_grep(&pattern_refs, constraint_refs, &options); + let items: Vec = result + .matches + .iter() + .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) + .collect(); + + Ok(( + items, + result.matches.len() as u32, + result.total_files_searched as u32, + result.total_files as u32, + result.filtered_file_count as u32, + result.next_file_offset as u32, + result.regex_fallback_error, + )) + })?; + + Ok(GrepResult { + items, + total_matched, + total_files_searched, + total_files, + filtered_file_count, + next_file_offset, + regex_fallback_error, + }) + } + + fn scan_files(&self) -> PyResult<()> { + self.picker + .trigger_full_rescan_async(&self.frecency) + .map_err(py_err) + } + + fn is_scanning(&self) -> PyResult { + let guard = self.picker.read().map_err(py_err)?; + Ok(guard.as_ref().map(|p| p.is_scan_active()).unwrap_or(false)) + } + + fn wait_for_scan(&self, timeout_ms: u64) -> PyResult { + Ok(self.picker.wait_for_scan(Duration::from_millis(timeout_ms))) + } + + fn get_scan_progress(&self) -> PyResult { + let guard = self.picker.read().map_err(py_err)?; + let picker = guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let p = picker.get_scan_progress(); + Ok(ScanProgress { + scanned_files_count: p.scanned_files_count as u64, + is_scanning: p.is_scanning, + is_watcher_ready: p.is_watcher_ready, + is_warmup_complete: p.is_warmup_complete, + }) + } + + fn get_base_path(&self) -> PyResult> { + let guard = self.picker.read().map_err(py_err)?; + Ok(guard + .as_ref() + .map(|p| p.base_path().to_string_lossy().to_string())) + } + + fn reindex(&self, new_path: PathBuf) -> PyResult<()> { + if !new_path.exists() { + return Err(py_err(format!( + "Path does not exist: {}", + new_path.display() + ))); + } + let canonical = fff::path_utils::canonicalize(&new_path).map_err(py_err)?; + + let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir) = { + let guard = self.picker.write().map_err(py_err)?; + if let Some(ref picker) = *guard { + ( + picker.has_mmap_cache(), + picker.has_content_indexing(), + picker.has_watcher(), + picker.mode(), + picker.fs_root_scanning_enabled(), + picker.home_dir_scanning_enabled(), + ) + } else { + (false, true, true, FFFMode::default(), false, false) + } + }; + + FilePicker::new_with_shared_state( + self.picker.clone(), + self.frecency.clone(), + FilePickerOptions { + base_path: canonical.to_string_lossy().to_string(), + enable_mmap_cache: warmup_caches, + enable_content_indexing: content_indexing, + watch, + mode, + cache_budget: fff::ContentCacheBudget::from_overrides( + self.cache_budget_max_files, + self.cache_budget_max_bytes, + self.cache_budget_max_file_size, + ), + follow_symlinks: false, + enable_fs_root_scanning: fs_root, + enable_home_dir_scanning: home_dir, + }, + ) + .map_err(py_err) + } + + fn refresh_git_status(&self) -> PyResult { + self.picker + .refresh_git_status(&self.frecency) + .map_err(py_err) + .map(|c| c as i64) + } + + #[pyo3(signature = (query, selected_file_path))] + fn track_query(&self, query: &str, selected_file_path: &str) -> PyResult { + let file_path = fff::path_utils::canonicalize(selected_file_path).map_err(py_err)?; + let project_path = { + let guard = self.picker.read().map_err(py_err)?; + guard.as_ref().map(|p| p.base_path().to_path_buf()) + }; + let project_path = match project_path { + Some(p) => p, + None => return Ok(false), + }; + + let mut qt_guard = self.query_tracker.write().map_err(py_err)?; + if let Some(ref mut tracker) = *qt_guard { + tracker + .track_query_completion(query, &project_path, &file_path) + .map_err(py_err)?; + Ok(true) + } else { + Ok(false) + } + } + + fn get_historical_query(&self, offset: u64) -> PyResult> { + let project_path = { + let guard = self.picker.read().map_err(py_err)?; + guard.as_ref().map(|p| p.base_path().to_path_buf()) + }; + let project_path = match project_path { + Some(p) => p, + None => return Ok(None), + }; + + let qt_guard = self.query_tracker.read().map_err(py_err)?; + if let Some(ref tracker) = *qt_guard { + tracker + .get_historical_query(&project_path, offset as usize) + .map_err(py_err) + } else { + Ok(None) + } + } + + #[pyo3(signature = (test_path=None))] + fn health_check(&self, py: Python<'_>, test_path: Option) -> PyResult> { + let test_path = test_path.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + + let dict = PyDict::new(py); + dict.set_item("version", env!("CARGO_PKG_VERSION"))?; + + let git_info = PyDict::new(py); + let git_version = git2::Version::get(); + let (major, minor, rev) = git_version.libgit2_version(); + git_info.set_item("libgit2_version", format!("{}.{}.{}", major, minor, rev))?; + match git2::Repository::discover(&test_path) { + Ok(repo) => { + git_info.set_item("available", true)?; + git_info.set_item("repository_found", true)?; + if let Some(workdir) = repo.workdir() { + git_info.set_item("workdir", workdir.to_string_lossy().to_string())?; + } + } + Err(e) => { + git_info.set_item("available", true)?; + git_info.set_item("repository_found", false)?; + git_info.set_item("error", e.message().to_string())?; + } + } + dict.set_item("git", git_info)?; + + let picker_info = PyDict::new(py); + { + let guard = self.picker.read().map_err(py_err)?; + if let Some(ref picker) = *guard { + picker_info.set_item("initialized", true)?; + picker_info.set_item( + "base_path", + picker.base_path().to_string_lossy().to_string(), + )?; + picker_info.set_item("is_scanning", picker.is_scan_active())?; + let progress = picker.get_scan_progress(); + picker_info.set_item("indexed_files", progress.scanned_files_count)?; + } else { + picker_info.set_item("initialized", false)?; + } + } + dict.set_item("file_picker", picker_info)?; + + let frecency_info = PyDict::new(py); + { + let guard = self.frecency.read().map_err(py_err)?; + frecency_info.set_item("initialized", guard.is_some())?; + } + dict.set_item("frecency", frecency_info)?; + + let query_info = PyDict::new(py); + { + let guard = self.query_tracker.read().map_err(py_err)?; + query_info.set_item("initialized", guard.is_some())?; + } + dict.set_item("query_tracker", query_info)?; + + Ok(dict.unbind()) + } +} diff --git a/crates/fff-python/src/lib.rs b/crates/fff-python/src/lib.rs index 99767e62..94d21946 100644 --- a/crates/fff-python/src/lib.rs +++ b/crates/fff-python/src/lib.rs @@ -1,16 +1,9 @@ -use std::path::PathBuf; -use std::time::Duration; - -use fff::file_picker::FilePicker; -use fff::frecency::FrecencyTracker; -use fff::query_tracker::QueryTracker; -use fff::{ - FFFMode, FilePickerOptions, FuzzySearchOptions, GrepSearchOptions, PaginationArgs, QueryParser, - SharedFilePicker, SharedFrecency, SharedQueryTracker, -}; use pyo3::create_exception; use pyo3::prelude::*; -use pyo3::types::PyDict; + +mod conversions; +mod finder; +mod types; create_exception!(fff_python, FFFException, pyo3::exceptions::PyException); @@ -18,1249 +11,34 @@ fn py_err(e: E) -> PyErr { PyErr::new::(format!("{}", e)) } -fn parse_grep_mode(mode: &str) -> fff::GrepMode { +pub fn parse_grep_mode(mode: &str) -> PyResult { match mode { - "regex" => fff::GrepMode::Regex, - "fuzzy" => fff::GrepMode::Fuzzy, - _ => fff::GrepMode::PlainText, - } -} - -// --------------------------------------------------------------------------- -// Result types -// --------------------------------------------------------------------------- - -#[pyclass] -#[derive(Clone)] -pub struct Score { - #[pyo3(get)] - pub total: i32, - #[pyo3(get)] - pub base_score: i32, - #[pyo3(get)] - pub filename_bonus: i32, - #[pyo3(get)] - pub special_filename_bonus: i32, - #[pyo3(get)] - pub frecency_boost: i32, - #[pyo3(get)] - pub distance_penalty: i32, - #[pyo3(get)] - pub current_file_penalty: i32, - #[pyo3(get)] - pub combo_match_boost: i32, - #[pyo3(get)] - pub path_alignment_bonus: i32, - #[pyo3(get)] - pub exact_match: bool, - #[pyo3(get)] - pub match_type: String, -} - -impl From<&fff::Score> for Score { - fn from(s: &fff::Score) -> Self { - Self { - total: s.total, - base_score: s.base_score, - filename_bonus: s.filename_bonus, - special_filename_bonus: s.special_filename_bonus, - frecency_boost: s.frecency_boost, - distance_penalty: s.distance_penalty, - current_file_penalty: s.current_file_penalty, - combo_match_boost: s.combo_match_boost, - path_alignment_bonus: s.path_alignment_bonus, - exact_match: s.exact_match, - match_type: s.match_type.to_string(), - } - } -} - -#[pyclass] -#[derive(Clone)] -pub struct FileItem { - #[pyo3(get)] - pub relative_path: String, - #[pyo3(get)] - pub file_name: String, - #[pyo3(get)] - pub git_status: String, - #[pyo3(get)] - pub size: u64, - #[pyo3(get)] - pub modified: u64, - #[pyo3(get)] - pub access_frecency_score: i64, - #[pyo3(get)] - pub modification_frecency_score: i64, - #[pyo3(get)] - pub total_frecency_score: i64, - #[pyo3(get)] - pub is_binary: bool, -} - -impl FileItem { - fn from_core(item: &fff::FileItem, picker: &FilePicker) -> Self { - Self { - relative_path: item.relative_path(picker), - file_name: item.file_name(picker), - git_status: fff::git::format_git_status(item.git_status).to_string(), - size: item.size, - modified: item.modified, - access_frecency_score: item.access_frecency_score as i64, - modification_frecency_score: item.modification_frecency_score as i64, - total_frecency_score: item.total_frecency_score() as i64, - is_binary: item.is_binary(), - } - } -} - -#[pyclass] -#[derive(Clone)] -pub struct DirItem { - #[pyo3(get)] - pub relative_path: String, - #[pyo3(get)] - pub dir_name: String, - #[pyo3(get)] - pub max_access_frecency: i32, -} - -impl DirItem { - fn from_core(item: &fff::DirItem, picker: &FilePicker) -> Self { - Self { - relative_path: item.relative_path(picker), - dir_name: item.dir_name(picker), - max_access_frecency: item.max_access_frecency(), - } - } -} - -impl MixedFileItem { - fn from_core(item: &fff::FileItem, picker: &FilePicker) -> Self { - Self { - relative_path: item.relative_path(picker), - file_name: item.file_name(picker), - git_status: fff::git::format_git_status(item.git_status).to_string(), - size: item.size, - modified: item.modified, - access_frecency_score: item.access_frecency_score as i64, - modification_frecency_score: item.modification_frecency_score as i64, - total_frecency_score: item.total_frecency_score() as i64, - is_binary: item.is_binary(), - } - } -} - -impl MixedDirItem { - fn from_core(item: &fff::DirItem, picker: &FilePicker) -> Self { - Self { - relative_path: item.relative_path(picker), - dir_name: item.dir_name(picker), - max_access_frecency: item.max_access_frecency() as i64, - } - } -} - -enum MixedItem { - File(MixedFileItem), - Dir(MixedDirItem), -} - -#[pyclass] -pub struct MixedFileItem { - #[pyo3(get)] - pub relative_path: String, - #[pyo3(get)] - pub file_name: String, - #[pyo3(get)] - pub git_status: String, - #[pyo3(get)] - pub size: u64, - #[pyo3(get)] - pub modified: u64, - #[pyo3(get)] - pub access_frecency_score: i64, - #[pyo3(get)] - pub modification_frecency_score: i64, - #[pyo3(get)] - pub total_frecency_score: i64, - #[pyo3(get)] - pub is_binary: bool, -} - -#[pyclass] -pub struct MixedDirItem { - #[pyo3(get)] - pub relative_path: String, - #[pyo3(get)] - pub dir_name: String, - #[pyo3(get)] - pub max_access_frecency: i64, -} - -#[pyclass] -#[derive(Clone)] -pub struct MatchRange { - #[pyo3(get)] - pub start: u32, - #[pyo3(get)] - pub end: u32, -} - -#[pyclass] -#[derive(Clone)] -pub struct GrepMatch { - #[pyo3(get)] - pub relative_path: String, - #[pyo3(get)] - pub file_name: String, - #[pyo3(get)] - pub git_status: String, - #[pyo3(get)] - pub line_content: String, - #[pyo3(get)] - pub match_ranges: Vec, - #[pyo3(get)] - pub context_before: Vec, - #[pyo3(get)] - pub context_after: Vec, - #[pyo3(get)] - pub size: u64, - #[pyo3(get)] - pub modified: u64, - #[pyo3(get)] - pub total_frecency_score: i64, - #[pyo3(get)] - pub access_frecency_score: i64, - #[pyo3(get)] - pub modification_frecency_score: i64, - #[pyo3(get)] - pub line_number: u64, - #[pyo3(get)] - pub byte_offset: u64, - #[pyo3(get)] - pub col: u32, - #[pyo3(get)] - pub fuzzy_score: Option, - #[pyo3(get)] - pub is_definition: bool, - #[pyo3(get)] - pub is_binary: bool, -} - -impl GrepMatch { - fn from_core(m: &fff::GrepMatch, file: &fff::FileItem, picker: &FilePicker) -> Self { - Self { - relative_path: file.relative_path(picker), - file_name: file.file_name(picker), - git_status: fff::git::format_git_status(file.git_status).to_string(), - line_content: m.line_content.clone(), - match_ranges: m - .match_byte_offsets - .iter() - .map(|&(s, e)| MatchRange { start: s, end: e }) - .collect(), - context_before: m.context_before.clone(), - context_after: m.context_after.clone(), - size: file.size, - modified: file.modified, - total_frecency_score: file.total_frecency_score() as i64, - access_frecency_score: file.access_frecency_score as i64, - modification_frecency_score: file.modification_frecency_score as i64, - line_number: m.line_number, - byte_offset: m.byte_offset, - col: m.col as u32, - fuzzy_score: m.fuzzy_score, - is_definition: m.is_definition, - is_binary: file.is_binary(), - } - } -} - -#[pyclass] -pub struct SearchResult { - #[pyo3(get)] - pub items: Vec, - #[pyo3(get)] - pub scores: Vec, - #[pyo3(get)] - pub total_matched: u32, - #[pyo3(get)] - pub total_files: u32, -} - -#[pyclass] -pub struct DirSearchResult { - #[pyo3(get)] - pub items: Vec, - #[pyo3(get)] - pub scores: Vec, - #[pyo3(get)] - pub total_matched: u32, - #[pyo3(get)] - pub total_dirs: u32, -} - -#[pyclass] -pub struct MixedSearchResult { - #[pyo3(get)] - pub items: Vec, - #[pyo3(get)] - pub scores: Vec, - #[pyo3(get)] - pub total_matched: u32, - #[pyo3(get)] - pub total_files: u32, - #[pyo3(get)] - pub total_dirs: u32, -} - -#[pyclass] -pub struct GrepResult { - #[pyo3(get)] - pub items: Vec, - #[pyo3(get)] - pub total_matched: u32, - #[pyo3(get)] - pub total_files_searched: u32, - #[pyo3(get)] - pub total_files: u32, - #[pyo3(get)] - pub filtered_file_count: u32, - #[pyo3(get)] - pub next_file_offset: u32, - #[pyo3(get)] - pub regex_fallback_error: Option, -} - -#[pyclass] -pub struct ScanProgress { - #[pyo3(get)] - pub scanned_files_count: u64, - #[pyo3(get)] - pub is_scanning: bool, - #[pyo3(get)] - pub is_watcher_ready: bool, - #[pyo3(get)] - pub is_warmup_complete: bool, -} - -#[pyclass] -pub struct GrepCursor { - #[pyo3(get)] - pub offset: u32, -} - -#[pymethods] -impl GrepCursor { - #[new] - fn new(offset: u32) -> Self { - Self { offset } - } -} - -// --------------------------------------------------------------------------- -// FileFinder -// --------------------------------------------------------------------------- - -#[pyclass] -pub struct FileFinder { - picker: SharedFilePicker, - frecency: SharedFrecency, - query_tracker: SharedQueryTracker, - cache_budget_max_files: usize, - cache_budget_max_bytes: u64, - cache_budget_max_file_size: u64, -} - -impl Drop for FileFinder { - fn drop(&mut self) { - if let Ok(mut guard) = self.picker.write() { - guard.take(); - } - if let Ok(mut guard) = self.frecency.write() { - *guard = None; - } - if let Ok(mut guard) = self.query_tracker.write() { - *guard = None; - } + "plain" => Ok(fff::GrepMode::PlainText), + "regex" => Ok(fff::GrepMode::Regex), + "fuzzy" => Ok(fff::GrepMode::Fuzzy), + _ => Err(py_err(format!( + "invalid grep mode: {:?}. Must be one of: plain, regex, fuzzy", + mode + ))), } } -#[pymethods] -impl FileFinder { - #[new] - #[pyo3(signature = ( - base_path, - frecency_db_path=None, - history_db_path=None, - enable_mmap_cache=true, - enable_content_indexing=true, - watch=true, - ai_mode=false, - log_file_path=None, - log_level=None, - cache_budget_max_files=0, - cache_budget_max_bytes=0, - cache_budget_max_file_size=0, - enable_fs_root_scanning=false, - enable_home_dir_scanning=false, - ))] - #[allow(clippy::too_many_arguments)] - fn new( - base_path: &str, - frecency_db_path: Option, - history_db_path: Option, - enable_mmap_cache: bool, - enable_content_indexing: bool, - watch: bool, - ai_mode: bool, - log_file_path: Option, - log_level: Option, - cache_budget_max_files: u64, - cache_budget_max_bytes: u64, - cache_budget_max_file_size: u64, - enable_fs_root_scanning: bool, - enable_home_dir_scanning: bool, - ) -> PyResult { - let shared_picker = SharedFilePicker::default(); - let shared_frecency = SharedFrecency::default(); - let query_tracker = SharedQueryTracker::default(); - - if let Some(path) = frecency_db_path { - let parent = PathBuf::from(&path).parent().map(PathBuf::from); - if let Some(p) = parent { - let _ = std::fs::create_dir_all(p); - } - let tracker = FrecencyTracker::open(&path).map_err(py_err)?; - shared_frecency.init(tracker).map_err(py_err)?; - } - - if let Some(path) = history_db_path { - let parent = PathBuf::from(&path).parent().map(PathBuf::from); - if let Some(p) = parent { - let _ = std::fs::create_dir_all(p); - } - let tracker = QueryTracker::open(&path).map_err(py_err)?; - query_tracker.init(tracker).map_err(py_err)?; - } - - if let Some(path) = log_file_path { - let level = log_level.as_deref(); - fff::log::init_tracing(&path, level, None).map_err(py_err)?; - } - - let mode = if ai_mode { - FFFMode::Ai - } else { - FFFMode::Neovim - }; - - let cache_budget = fff::ContentCacheBudget::from_overrides( - cache_budget_max_files as usize, - cache_budget_max_bytes, - cache_budget_max_file_size, - ); - - FilePicker::new_with_shared_state( - shared_picker.clone(), - shared_frecency.clone(), - FilePickerOptions { - base_path: base_path.to_string(), - enable_mmap_cache, - enable_content_indexing, - watch, - mode, - cache_budget, - follow_symlinks: false, - enable_fs_root_scanning, - enable_home_dir_scanning, - }, - ) - .map_err(py_err)?; - - Ok(Self { - picker: shared_picker, - frecency: shared_frecency, - query_tracker, - cache_budget_max_files: cache_budget_max_files as usize, - cache_budget_max_bytes, - cache_budget_max_file_size, - }) - } - - fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { - slf - } - - fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) { - let _ = self.destroy(); - } - - fn destroy(&mut self) -> PyResult<()> { - if let Ok(mut guard) = self.picker.write() { - *guard = None; - } - if let Ok(mut guard) = self.frecency.write() { - *guard = None; - } - if let Ok(mut guard) = self.query_tracker.write() { - *guard = None; - } - Ok(()) - } - - #[allow(clippy::too_many_arguments)] - #[pyo3(signature = ( - query, - current_file=None, - max_threads=0, - page_index=0, - page_size=0, - combo_boost_score_multiplier=0, - min_combo_count=0, - ))] - fn search( - &self, - py: Python<'_>, - query: &str, - current_file: Option, - max_threads: u32, - page_index: u32, - page_size: u32, - combo_boost_score_multiplier: i32, - min_combo_count: u32, - ) -> PyResult { - let picker = self.picker.clone(); - let query_tracker = self.query_tracker.clone(); - let query = query.to_string(); - - let (items, scores, total_matched, total_files) = - py.allow_threads(move || -> PyResult<_> { - let picker_guard = picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - let qt_guard = query_tracker.read().map_err(py_err)?; - - let parser = QueryParser::default(); - let parsed = parser.parse(&query); - let result = picker.fuzzy_search( - &parsed, - qt_guard.as_ref(), - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier, - min_combo_count, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize - }, - }, - }, - ); - - let items: Vec = result - .items - .iter() - .map(|i| FileItem::from_core(i, picker)) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); - - Ok(( - items, - scores, - result.total_matched as u32, - result.total_files as u32, - )) - })?; - - Ok(SearchResult { - items, - scores, - total_matched, - total_files, - }) - } - - #[pyo3(signature = ( - pattern, - current_file=None, - max_threads=0, - page_index=0, - page_size=0, - ))] - fn glob( - &self, - py: Python<'_>, - pattern: &str, - current_file: Option, - max_threads: u32, - page_index: u32, - page_size: u32, - ) -> PyResult { - let picker = self.picker.clone(); - let pattern = pattern.to_string(); - - let (items, scores, total_matched, total_files) = - py.allow_threads(move || -> PyResult<_> { - let picker_guard = picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - let result = picker.glob( - &pattern, - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier: 0, - min_combo_count: 0, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize - }, - }, - }, - ); - - let items: Vec = result - .items - .iter() - .map(|i| FileItem::from_core(i, picker)) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); - - Ok(( - items, - scores, - result.total_matched as u32, - result.total_files as u32, - )) - })?; - - Ok(SearchResult { - items, - scores, - total_matched, - total_files, - }) - } - - #[pyo3(signature = ( - query, - current_file=None, - max_threads=0, - page_index=0, - page_size=0, - ))] - fn directory_search( - &self, - py: Python<'_>, - query: &str, - current_file: Option, - max_threads: u32, - page_index: u32, - page_size: u32, - ) -> PyResult { - let picker = self.picker.clone(); - let query = query.to_string(); - - let (items, scores, total_matched, total_dirs) = - py.allow_threads(move || -> PyResult<_> { - let picker_guard = picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - let parser = QueryParser::new(fff_query_parser::DirSearchConfig); - let parsed = parser.parse(&query); - let result = picker.fuzzy_search_directories( - &parsed, - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier: 0, - min_combo_count: 0, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize - }, - }, - }, - ); - - let items: Vec = result - .items - .iter() - .map(|i| DirItem::from_core(i, picker)) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); - - Ok(( - items, - scores, - result.total_matched as u32, - result.total_dirs as u32, - )) - })?; - - Ok(DirSearchResult { - items, - scores, - total_matched, - total_dirs, - }) - } - - #[allow(clippy::too_many_arguments)] - #[pyo3(signature = ( - query, - current_file=None, - max_threads=0, - page_index=0, - page_size=0, - combo_boost_score_multiplier=0, - min_combo_count=0, - ))] - fn mixed_search( - &self, - py: Python<'_>, - query: &str, - current_file: Option, - max_threads: u32, - page_index: u32, - page_size: u32, - combo_boost_score_multiplier: i32, - min_combo_count: u32, - ) -> PyResult { - let picker = self.picker.clone(); - let query_tracker = self.query_tracker.clone(); - let query = query.to_string(); - - let (items, scores, total_matched, total_files, total_dirs) = - py.allow_threads(move || -> PyResult<_> { - let picker_guard = picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - let qt_guard = query_tracker.read().map_err(py_err)?; - - let parser = QueryParser::new(fff_query_parser::MixedSearchConfig); - let parsed = parser.parse(&query); - let result = picker.fuzzy_search_mixed( - &parsed, - qt_guard.as_ref(), - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier, - min_combo_count, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize - }, - }, - }, - ); - - let items: Vec = result - .items - .iter() - .map(|item| match item { - fff::MixedItemRef::File(file) => { - MixedItem::File(MixedFileItem::from_core(file, picker)) - } - fff::MixedItemRef::Dir(dir) => { - MixedItem::Dir(MixedDirItem::from_core(dir, picker)) - } - }) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); - - Ok(( - items, - scores, - result.total_matched as u32, - result.total_files as u32, - result.total_dirs as u32, - )) - })?; - - let items: PyResult> = items - .into_iter() - .map(|item| match item { - MixedItem::File(file) => Ok(Py::new(py, file)?.into_any()), - MixedItem::Dir(dir) => Ok(Py::new(py, dir)?.into_any()), - }) - .collect(); - - Ok(MixedSearchResult { - items: items?, - scores, - total_matched, - total_files, - total_dirs, - }) - } - - #[allow(clippy::too_many_arguments)] - #[pyo3(signature = ( - query, - mode="plain", - max_file_size=0, - max_matches_per_file=0, - smart_case=true, - cursor=None, - page_limit=0, - time_budget_ms=0, - before_context=0, - after_context=0, - classify_definitions=false, - ))] - fn grep( - &self, - py: Python<'_>, - query: &str, - mode: &str, - max_file_size: u64, - max_matches_per_file: u32, - smart_case: bool, - cursor: Option<&GrepCursor>, - page_limit: u32, - time_budget_ms: u64, - before_context: u32, - after_context: u32, - classify_definitions: bool, - ) -> PyResult { - let picker = self.picker.clone(); - let query = query.to_string(); - let mode = parse_grep_mode(mode); - - let ( - items, - total_matched, - total_files_searched, - total_files, - filtered_file_count, - next_file_offset, - regex_fallback_error, - ) = py.allow_threads(move || -> PyResult<_> { - let picker_guard = picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - let is_ai = picker.mode().is_ai(); - let parsed = if is_ai { - QueryParser::new(fff_query_parser::AiGrepConfig).parse(&query) - } else { - fff::grep::parse_grep_query(&query) - }; - - let options = GrepSearchOptions { - max_file_size: if max_file_size == 0 { - 10 * 1024 * 1024 - } else { - max_file_size - }, - max_matches_per_file: max_matches_per_file as usize, - smart_case, - file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), - page_limit: if page_limit == 0 { - 50 - } else { - page_limit as usize - }, - mode, - time_budget_ms, - before_context: before_context as usize, - after_context: after_context as usize, - classify_definitions, - trim_whitespace: false, - abort_signal: None, - }; - - let result = picker.grep(&parsed, &options); - let items: Vec = result - .matches - .iter() - .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) - .collect(); - - Ok(( - items, - result.matches.len() as u32, - result.total_files_searched as u32, - result.total_files as u32, - result.filtered_file_count as u32, - result.next_file_offset as u32, - result.regex_fallback_error, - )) - })?; - - Ok(GrepResult { - items, - total_matched, - total_files_searched, - total_files, - filtered_file_count, - next_file_offset, - regex_fallback_error, - }) - } - - #[allow(clippy::too_many_arguments)] - #[pyo3(signature = ( - patterns, - constraints=None, - mode="plain", - max_file_size=0, - max_matches_per_file=0, - smart_case=true, - cursor=None, - page_limit=0, - time_budget_ms=0, - before_context=0, - after_context=0, - classify_definitions=false, - ))] - fn multi_grep( - &self, - py: Python<'_>, - patterns: Vec, - constraints: Option, - mode: &str, - max_file_size: u64, - max_matches_per_file: u32, - smart_case: bool, - cursor: Option<&GrepCursor>, - page_limit: u32, - time_budget_ms: u64, - before_context: u32, - after_context: u32, - classify_definitions: bool, - ) -> PyResult { - let picker = self.picker.clone(); - let mode = parse_grep_mode(mode); - - let ( - items, - total_matched, - total_files_searched, - total_files, - filtered_file_count, - next_file_offset, - regex_fallback_error, - ) = py.allow_threads(move || -> PyResult<_> { - let picker_guard = picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - - if patterns.is_empty() || patterns.iter().all(|p| p.is_empty()) { - return Err(py_err("patterns must not be empty")); - } - let pattern_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect(); - - let is_ai = picker.mode().is_ai(); - let parsed_constraints = constraints.as_ref().map(|c| { - if is_ai { - QueryParser::new(fff_query_parser::AiGrepConfig).parse(c) - } else { - fff::grep::parse_grep_query(c) - } - }); - let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints { - Some(q) => &q.constraints, - None => &[], - }; - - let options = GrepSearchOptions { - max_file_size: if max_file_size == 0 { - 10 * 1024 * 1024 - } else { - max_file_size - }, - max_matches_per_file: max_matches_per_file as usize, - smart_case, - file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), - page_limit: if page_limit == 0 { - 50 - } else { - page_limit as usize - }, - mode, - time_budget_ms, - before_context: before_context as usize, - after_context: after_context as usize, - classify_definitions, - trim_whitespace: false, - abort_signal: None, - }; - - let result = picker.multi_grep(&pattern_refs, constraint_refs, &options); - let items: Vec = result - .matches - .iter() - .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) - .collect(); - - Ok(( - items, - result.matches.len() as u32, - result.total_files_searched as u32, - result.total_files as u32, - result.filtered_file_count as u32, - result.next_file_offset as u32, - result.regex_fallback_error, - )) - })?; - - Ok(GrepResult { - items, - total_matched, - total_files_searched, - total_files, - filtered_file_count, - next_file_offset, - regex_fallback_error, - }) - } - - fn scan_files(&self) -> PyResult<()> { - self.picker - .trigger_full_rescan_async(&self.frecency) - .map_err(py_err) - } - - fn is_scanning(&self) -> PyResult { - let guard = self.picker.read().map_err(py_err)?; - Ok(guard.as_ref().map(|p| p.is_scan_active()).unwrap_or(false)) - } - - fn wait_for_scan(&self, timeout_ms: u64) -> PyResult { - Ok(self.picker.wait_for_scan(Duration::from_millis(timeout_ms))) - } - - fn get_scan_progress(&self) -> PyResult { - let guard = self.picker.read().map_err(py_err)?; - let picker = guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - let p = picker.get_scan_progress(); - Ok(ScanProgress { - scanned_files_count: p.scanned_files_count as u64, - is_scanning: p.is_scanning, - is_watcher_ready: p.is_watcher_ready, - is_warmup_complete: p.is_warmup_complete, - }) - } - - fn get_base_path(&self) -> PyResult> { - let guard = self.picker.read().map_err(py_err)?; - Ok(guard - .as_ref() - .map(|p| p.base_path().to_string_lossy().to_string())) - } - - fn reindex(&self, new_path: &str) -> PyResult<()> { - let path = PathBuf::from(new_path); - if !path.exists() { - return Err(py_err(format!("Path does not exist: {}", new_path))); - } - let canonical = fff::path_utils::canonicalize(&path).map_err(py_err)?; - - let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir) = { - let guard = self.picker.write().map_err(py_err)?; - if let Some(ref picker) = *guard { - ( - picker.has_mmap_cache(), - picker.has_content_indexing(), - picker.has_watcher(), - picker.mode(), - picker.fs_root_scanning_enabled(), - picker.home_dir_scanning_enabled(), - ) - } else { - (false, true, true, FFFMode::default(), false, false) - } - }; - - FilePicker::new_with_shared_state( - self.picker.clone(), - self.frecency.clone(), - FilePickerOptions { - base_path: canonical.to_string_lossy().to_string(), - enable_mmap_cache: warmup_caches, - enable_content_indexing: content_indexing, - watch, - mode, - cache_budget: fff::ContentCacheBudget::from_overrides( - self.cache_budget_max_files, - self.cache_budget_max_bytes, - self.cache_budget_max_file_size, - ), - follow_symlinks: false, - enable_fs_root_scanning: fs_root, - enable_home_dir_scanning: home_dir, - }, - ) - .map_err(py_err) - } - - fn refresh_git_status(&self) -> PyResult { - self.picker - .refresh_git_status(&self.frecency) - .map_err(py_err) - .map(|c| c as i64) - } - - #[pyo3(signature = (query, selected_file_path))] - fn track_query(&self, query: &str, selected_file_path: &str) -> PyResult { - let file_path = fff::path_utils::canonicalize(selected_file_path).map_err(py_err)?; - let project_path = { - let guard = self.picker.read().map_err(py_err)?; - guard.as_ref().map(|p| p.base_path().to_path_buf()) - }; - let project_path = match project_path { - Some(p) => p, - None => return Ok(false), - }; - - let mut qt_guard = self.query_tracker.write().map_err(py_err)?; - if let Some(ref mut tracker) = *qt_guard { - tracker - .track_query_completion(query, &project_path, &file_path) - .map_err(py_err)?; - Ok(true) - } else { - Ok(false) - } - } - - fn get_historical_query(&self, offset: u64) -> PyResult> { - let project_path = { - let guard = self.picker.read().map_err(py_err)?; - guard.as_ref().map(|p| p.base_path().to_path_buf()) - }; - let project_path = match project_path { - Some(p) => p, - None => return Ok(None), - }; - - let qt_guard = self.query_tracker.read().map_err(py_err)?; - if let Some(ref tracker) = *qt_guard { - tracker - .get_historical_query(&project_path, offset as usize) - .map_err(py_err) - } else { - Ok(None) - } - } - - #[pyo3(signature = (test_path=None))] - fn health_check(&self, py: Python<'_>, test_path: Option) -> PyResult> { - let test_path = test_path - .map(PathBuf::from) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - - let dict = PyDict::new(py); - dict.set_item("version", env!("CARGO_PKG_VERSION"))?; - - let git_info = PyDict::new(py); - let git_version = git2::Version::get(); - let (major, minor, rev) = git_version.libgit2_version(); - git_info.set_item("libgit2_version", format!("{}.{}.{}", major, minor, rev))?; - match git2::Repository::discover(&test_path) { - Ok(repo) => { - git_info.set_item("available", true)?; - git_info.set_item("repository_found", true)?; - if let Some(workdir) = repo.workdir() { - git_info.set_item("workdir", workdir.to_string_lossy().to_string())?; - } - } - Err(e) => { - git_info.set_item("available", true)?; - git_info.set_item("repository_found", false)?; - git_info.set_item("error", e.message().to_string())?; - } - } - dict.set_item("git", git_info)?; - - let picker_info = PyDict::new(py); - { - let guard = self.picker.read().map_err(py_err)?; - if let Some(ref picker) = *guard { - picker_info.set_item("initialized", true)?; - picker_info.set_item( - "base_path", - picker.base_path().to_string_lossy().to_string(), - )?; - picker_info.set_item("is_scanning", picker.is_scan_active())?; - let progress = picker.get_scan_progress(); - picker_info.set_item("indexed_files", progress.scanned_files_count)?; - } else { - picker_info.set_item("initialized", false)?; - } - } - dict.set_item("file_picker", picker_info)?; - - let frecency_info = PyDict::new(py); - { - let guard = self.frecency.read().map_err(py_err)?; - frecency_info.set_item("initialized", guard.is_some())?; - } - dict.set_item("frecency", frecency_info)?; - - let query_info = PyDict::new(py); - { - let guard = self.query_tracker.read().map_err(py_err)?; - query_info.set_item("initialized", guard.is_some())?; - } - dict.set_item("query_tracker", query_info)?; - - Ok(dict.unbind()) - } -} - -// --------------------------------------------------------------------------- -// Module -// --------------------------------------------------------------------------- - #[pymodule] fn _fff_python(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add("FFFException", m.py().get_type::())?; Ok(()) } diff --git a/crates/fff-python/src/types.rs b/crates/fff-python/src/types.rs new file mode 100644 index 00000000..da4ea310 --- /dev/null +++ b/crates/fff-python/src/types.rs @@ -0,0 +1,376 @@ +use pyo3::prelude::*; + +#[pyclass] +#[derive(Clone)] +pub struct Score { + #[pyo3(get)] + pub total: i32, + #[pyo3(get)] + pub base_score: i32, + #[pyo3(get)] + pub filename_bonus: i32, + #[pyo3(get)] + pub special_filename_bonus: i32, + #[pyo3(get)] + pub frecency_boost: i32, + #[pyo3(get)] + pub distance_penalty: i32, + #[pyo3(get)] + pub current_file_penalty: i32, + #[pyo3(get)] + pub combo_match_boost: i32, + #[pyo3(get)] + pub path_alignment_bonus: i32, + #[pyo3(get)] + pub exact_match: bool, + #[pyo3(get)] + pub match_type: String, +} + +#[pymethods] +impl Score { + fn __repr__(&self) -> String { + format!( + "Score(total={}, base_score={}, filename_bonus={}, match_type={:?})", + self.total, self.base_score, self.filename_bonus, self.match_type + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct FileItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub file_name: String, + #[pyo3(get)] + pub git_status: String, + #[pyo3(get)] + pub size: u64, + #[pyo3(get)] + pub modified: u64, + #[pyo3(get)] + pub access_frecency_score: i64, + #[pyo3(get)] + pub modification_frecency_score: i64, + #[pyo3(get)] + pub total_frecency_score: i64, + #[pyo3(get)] + pub is_binary: bool, +} + +#[pymethods] +impl FileItem { + fn __repr__(&self) -> String { + format!( + "FileItem(relative_path={:?}, file_name={:?}, size={})", + self.relative_path, self.file_name, self.size + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct DirItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub dir_name: String, + #[pyo3(get)] + pub max_access_frecency: i32, +} + +#[pymethods] +impl DirItem { + fn __repr__(&self) -> String { + format!( + "DirItem(relative_path={:?}, dir_name={:?})", + self.relative_path, self.dir_name + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct MixedFileItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub file_name: String, + #[pyo3(get)] + pub git_status: String, + #[pyo3(get)] + pub size: u64, + #[pyo3(get)] + pub modified: u64, + #[pyo3(get)] + pub access_frecency_score: i64, + #[pyo3(get)] + pub modification_frecency_score: i64, + #[pyo3(get)] + pub total_frecency_score: i64, + #[pyo3(get)] + pub is_binary: bool, +} + +#[pymethods] +impl MixedFileItem { + fn __repr__(&self) -> String { + format!( + "MixedFileItem(relative_path={:?}, file_name={:?}, size={})", + self.relative_path, self.file_name, self.size + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct MixedDirItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub dir_name: String, + #[pyo3(get)] + pub max_access_frecency: i64, +} + +#[pymethods] +impl MixedDirItem { + fn __repr__(&self) -> String { + format!( + "MixedDirItem(relative_path={:?}, dir_name={:?})", + self.relative_path, self.dir_name + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct MatchRange { + #[pyo3(get)] + pub start: u32, + #[pyo3(get)] + pub end: u32, +} + +#[pymethods] +impl MatchRange { + fn __repr__(&self) -> String { + format!("MatchRange(start={}, end={})", self.start, self.end) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct GrepMatch { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub file_name: String, + #[pyo3(get)] + pub git_status: String, + #[pyo3(get)] + pub line_content: String, + #[pyo3(get)] + pub match_ranges: Vec, + #[pyo3(get)] + pub context_before: Vec, + #[pyo3(get)] + pub context_after: Vec, + #[pyo3(get)] + pub size: u64, + #[pyo3(get)] + pub modified: u64, + #[pyo3(get)] + pub total_frecency_score: i64, + #[pyo3(get)] + pub access_frecency_score: i64, + #[pyo3(get)] + pub modification_frecency_score: i64, + #[pyo3(get)] + pub line_number: u64, + #[pyo3(get)] + pub byte_offset: u64, + #[pyo3(get)] + pub col: u32, + #[pyo3(get)] + pub fuzzy_score: Option, + #[pyo3(get)] + pub is_definition: bool, + #[pyo3(get)] + pub is_binary: bool, +} + +#[pymethods] +impl GrepMatch { + fn __repr__(&self) -> String { + format!( + "GrepMatch(relative_path={:?}, line_number={}, line_content={:?})", + self.relative_path, self.line_number, self.line_content + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct SearchResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub scores: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_files: u32, +} + +#[pymethods] +impl SearchResult { + fn __repr__(&self) -> String { + format!( + "SearchResult(items={}, total_matched={}, total_files={})", + self.items.len(), + self.total_matched, + self.total_files + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct DirSearchResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub scores: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_dirs: u32, +} + +#[pymethods] +impl DirSearchResult { + fn __repr__(&self) -> String { + format!( + "DirSearchResult(items={}, total_matched={}, total_dirs={})", + self.items.len(), + self.total_matched, + self.total_dirs + ) + } +} + +#[pyclass] +pub struct MixedSearchResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub scores: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_files: u32, + #[pyo3(get)] + pub total_dirs: u32, +} + +#[pymethods] +impl MixedSearchResult { + fn __repr__(&self) -> String { + format!( + "MixedSearchResult(items={}, total_matched={}, total_files={}, total_dirs={})", + self.items.len(), + self.total_matched, + self.total_files, + self.total_dirs + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct GrepResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_files_searched: u32, + #[pyo3(get)] + pub total_files: u32, + #[pyo3(get)] + pub filtered_file_count: u32, + #[pyo3(get)] + pub next_file_offset: u32, + #[pyo3(get)] + pub regex_fallback_error: Option, +} + +#[pymethods] +impl GrepResult { + fn __repr__(&self) -> String { + format!( + "GrepResult(items={}, total_matched={}, next_file_offset={})", + self.items.len(), + self.total_matched, + self.next_file_offset + ) + } + + #[getter] + fn has_more(&self) -> bool { + self.next_file_offset > 0 + } + + fn next_cursor(&self, py: Python<'_>) -> Option> { + if self.next_file_offset > 0 { + Py::new(py, GrepCursor::new(self.next_file_offset)).ok() + } else { + None + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct ScanProgress { + #[pyo3(get)] + pub scanned_files_count: u64, + #[pyo3(get)] + pub is_scanning: bool, + #[pyo3(get)] + pub is_watcher_ready: bool, + #[pyo3(get)] + pub is_warmup_complete: bool, +} + +#[pymethods] +impl ScanProgress { + fn __repr__(&self) -> String { + format!( + "ScanProgress(scanned_files_count={}, is_scanning={})", + self.scanned_files_count, self.is_scanning + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct GrepCursor { + #[pyo3(get)] + pub offset: u32, +} + +#[pymethods] +impl GrepCursor { + #[new] + fn new(offset: u32) -> Self { + Self { offset } + } + + fn __repr__(&self) -> String { + format!("GrepCursor(offset={})", self.offset) + } +} diff --git a/packages/fff-python/src/fff/__init__.pyi b/packages/fff-python/src/fff/__init__.pyi new file mode 100644 index 00000000..7285c869 --- /dev/null +++ b/packages/fff-python/src/fff/__init__.pyi @@ -0,0 +1,239 @@ +"""Type stubs for fff Python bindings.""" + +from __future__ import annotations + +from os import PathLike +from typing import Any, Dict, List, Optional, Union + +__version__: str + +class FFFException(Exception): + """Base exception for fff errors.""" + +class Score: + total: int + base_score: int + filename_bonus: int + special_filename_bonus: int + frecency_boost: int + distance_penalty: int + current_file_penalty: int + combo_match_boost: int + path_alignment_bonus: int + exact_match: bool + match_type: str + def __repr__(self) -> str: ... + +class FileItem: + relative_path: str + file_name: str + git_status: str + size: int + modified: int + access_frecency_score: int + modification_frecency_score: int + total_frecency_score: int + is_binary: bool + def __repr__(self) -> str: ... + +class DirItem: + relative_path: str + dir_name: str + max_access_frecency: int + def __repr__(self) -> str: ... + +class MixedFileItem: + relative_path: str + file_name: str + git_status: str + size: int + modified: int + access_frecency_score: int + modification_frecency_score: int + total_frecency_score: int + is_binary: bool + def __repr__(self) -> str: ... + +class MixedDirItem: + relative_path: str + dir_name: str + max_access_frecency: int + def __repr__(self) -> str: ... + +class MatchRange: + start: int + end: int + def __repr__(self) -> str: ... + +class GrepMatch: + relative_path: str + file_name: str + git_status: str + line_content: str + match_ranges: List[MatchRange] + context_before: List[str] + context_after: List[str] + size: int + modified: int + total_frecency_score: int + access_frecency_score: int + modification_frecency_score: int + line_number: int + byte_offset: int + col: int + fuzzy_score: Optional[int] + is_definition: bool + is_binary: bool + def __repr__(self) -> str: ... + +class SearchResult: + items: List[FileItem] + scores: List[Score] + total_matched: int + total_files: int + def __repr__(self) -> str: ... + +class DirSearchResult: + items: List[DirItem] + scores: List[Score] + total_matched: int + total_dirs: int + def __repr__(self) -> str: ... + +class MixedSearchResult: + items: List[Union[MixedFileItem, MixedDirItem]] + scores: List[Score] + total_matched: int + total_files: int + total_dirs: int + def __repr__(self) -> str: ... + +class GrepResult: + items: List[GrepMatch] + total_matched: int + total_files_searched: int + total_files: int + filtered_file_count: int + next_file_offset: int + regex_fallback_error: Optional[str] + @property + def has_more(self) -> bool: ... + def next_cursor(self) -> Optional[GrepCursor]: ... + def __repr__(self) -> str: ... + +class ScanProgress: + scanned_files_count: int + is_scanning: bool + is_watcher_ready: bool + is_warmup_complete: bool + def __repr__(self) -> str: ... + +class GrepCursor: + offset: int + def __init__(self, offset: int = ...) -> None: ... + def __repr__(self) -> str: ... + +class FileFinder: + def __init__( + self, + base_path: Union[str, PathLike[str]], + *, + frecency_db_path: Optional[str] = None, + history_db_path: Optional[str] = None, + enable_mmap_cache: bool = True, + enable_content_indexing: bool = True, + watch: bool = True, + ai_mode: bool = False, + log_file_path: Optional[str] = None, + log_level: Optional[str] = None, + cache_budget_max_files: int = 0, + cache_budget_max_bytes: int = 0, + cache_budget_max_file_size: int = 0, + enable_fs_root_scanning: bool = False, + enable_home_dir_scanning: bool = False, + ) -> None: ... + def __enter__(self) -> FileFinder: ... + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... + def destroy(self) -> None: ... + def close(self) -> None: ... + def search( + self, + query: str, + *, + current_file: Optional[str] = None, + max_threads: int = 0, + page_index: int = 0, + page_size: int = 0, + combo_boost_score_multiplier: int = 0, + min_combo_count: int = 0, + ) -> SearchResult: ... + def glob( + self, + pattern: str, + *, + current_file: Optional[str] = None, + max_threads: int = 0, + page_index: int = 0, + page_size: int = 0, + ) -> SearchResult: ... + def directory_search( + self, + query: str, + *, + current_file: Optional[str] = None, + max_threads: int = 0, + page_index: int = 0, + page_size: int = 0, + ) -> DirSearchResult: ... + def mixed_search( + self, + query: str, + *, + current_file: Optional[str] = None, + max_threads: int = 0, + page_index: int = 0, + page_size: int = 0, + combo_boost_score_multiplier: int = 0, + min_combo_count: int = 0, + ) -> MixedSearchResult: ... + def grep( + self, + query: str, + *, + mode: str = "plain", + max_file_size: int = 0, + max_matches_per_file: int = 0, + smart_case: bool = True, + cursor: Optional[GrepCursor] = None, + page_limit: int = 0, + time_budget_ms: int = 0, + before_context: int = 0, + after_context: int = 0, + classify_definitions: bool = False, + ) -> GrepResult: ... + def multi_grep( + self, + patterns: List[str], + *, + constraints: Optional[str] = None, + mode: str = "plain", + max_file_size: int = 0, + max_matches_per_file: int = 0, + smart_case: bool = True, + cursor: Optional[GrepCursor] = None, + page_limit: int = 0, + time_budget_ms: int = 0, + before_context: int = 0, + after_context: int = 0, + classify_definitions: bool = False, + ) -> GrepResult: ... + def scan_files(self) -> None: ... + def is_scanning(self) -> bool: ... + def wait_for_scan(self, timeout_ms: int) -> bool: ... + def get_scan_progress(self) -> ScanProgress: ... + def get_base_path(self) -> Optional[str]: ... + def reindex(self, new_path: Union[str, PathLike[str]]) -> None: ... + def refresh_git_status(self) -> int: ... + def track_query(self, query: str, selected_file_path: str) -> bool: ... + def get_historical_query(self, offset: int) -> Optional[str]: ... + def health_check(self, test_path: Optional[Union[str, PathLike[str]]] = None) -> Dict[str, Any]: ... diff --git a/packages/fff-python/src/fff/py.typed b/packages/fff-python/src/fff/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py index 8d4de136..1222262c 100644 --- a/packages/fff-python/tests/test_finder.py +++ b/packages/fff-python/tests/test_finder.py @@ -51,7 +51,15 @@ def test_imports_and_package_version() -> None: assert "GrepCursor" in fff.__all__ -def test_create_destroy_and_context_manager(sample_dir: str) -> None: +def test_pathlib_base_path(sample_dir: str) -> None: + with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + assert finder.get_base_path() is not None + result = finder.search("main") + assert result.total_matched >= 1 + + +def test_create_destroy_close_and_context_manager(sample_dir: str) -> None: finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False) assert finder.wait_for_scan(timeout_ms=5000) assert finder.get_base_path() is not None @@ -66,6 +74,39 @@ def test_create_destroy_and_context_manager(sample_dir: str) -> None: with pytest.raises(FFFException, match="File picker not initialized"): ctx_finder.search("main") + fresh = FileFinder(sample_dir, watch=False, enable_content_indexing=False) + assert fresh.wait_for_scan(timeout_ms=5000) + fresh.close() + with pytest.raises(FFFException, match="File picker not initialized"): + fresh.search("main") + + +def test_reprs(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + result = finder.search("main") + assert repr(result).startswith("SearchResult(") + assert repr(result.items[0]).startswith("FileItem(") + assert repr(result.scores[0]).startswith("Score(") + + grep_result = finder.grep("needle") + assert repr(grep_result).startswith("GrepResult(") + assert repr(grep_result.items[0]).startswith("GrepMatch(") + assert repr(grep_result.items[0].match_ranges[0]).startswith("MatchRange(") + + dir_result = finder.directory_search("src") + assert repr(dir_result).startswith("DirSearchResult(") + assert repr(dir_result.items[0]).startswith("DirItem(") + + mixed = finder.mixed_search("src", page_size=10) + assert repr(mixed).startswith("MixedSearchResult(") + + cursor = GrepCursor(42) + assert repr(cursor) == "GrepCursor(offset=42)" + + progress = finder.get_scan_progress() + assert repr(progress).startswith("ScanProgress(") + def test_file_search_scores_and_pagination(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: @@ -145,6 +186,15 @@ def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None: assert invalid.regex_fallback_error is not None +def test_grep_invalid_mode_raises(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + with pytest.raises(FFFException, match="invalid grep mode"): + finder.grep("needle", mode="typo") + with pytest.raises(FFFException, match="invalid grep mode"): + finder.multi_grep(["needle"], mode="typo") + + def test_grep_cursor_paginates_by_file(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: assert finder.wait_for_scan(timeout_ms=5000) @@ -152,6 +202,9 @@ def test_grep_cursor_paginates_by_file(sample_dir: str) -> None: first = finder.grep("def", page_limit=1) assert first.total_matched >= 1 assert first.next_file_offset > 0 + assert first.has_more is True + assert first.next_cursor() is not None + assert first.next_cursor().offset == first.next_file_offset second = finder.grep("def", cursor=GrepCursor(first.next_file_offset), page_limit=1) assert second.total_matched >= 1 @@ -160,6 +213,10 @@ def test_grep_cursor_paginates_by_file(sample_dir: str) -> None: second_paths = {rel(m.relative_path) for m in second.items} assert first_paths.isdisjoint(second_paths) + exhausted = finder.grep("nonexistent_xyz") + assert exhausted.has_more is False + assert exhausted.next_cursor() is None + def test_multi_grep_and_error_handling(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: @@ -226,3 +283,8 @@ def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None: result = finder.search("other") assert result.total_matched == 1 assert rel(result.items[0].relative_path) == "other.py" + + finder.reindex(Path(other)) + assert finder.wait_for_scan(timeout_ms=5000) + result2 = finder.search("other") + assert result2.total_matched == 1 From c5f55c22b475d819993409d5b8093e3f3eb51de1 Mon Sep 17 00:00:00 2001 From: 4fu Date: Tue, 16 Jun 2026 13:45:47 +0800 Subject: [PATCH 06/15] fix(python): align type stubs and runtime API --- crates/fff-python/src/finder.rs | 7 +++++++ crates/fff-python/src/types.rs | 6 +++--- packages/fff-python/pyproject.toml | 1 + packages/fff-python/src/fff/__init__.pyi | 2 +- packages/fff-python/tests/test_finder.py | 24 ++++++++++++++++++++++++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index 240787f9..61c5f999 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -47,6 +47,7 @@ impl FileFinder { #[new] #[pyo3(signature = ( base_path, + *, frecency_db_path=None, history_db_path=None, enable_mmap_cache=true, @@ -172,6 +173,7 @@ impl FileFinder { #[allow(clippy::too_many_arguments)] #[pyo3(signature = ( query, + *, current_file=None, max_threads=0, page_index=0, @@ -249,6 +251,7 @@ impl FileFinder { #[pyo3(signature = ( pattern, + *, current_file=None, max_threads=0, page_index=0, @@ -317,6 +320,7 @@ impl FileFinder { #[pyo3(signature = ( query, + *, current_file=None, max_threads=0, page_index=0, @@ -388,6 +392,7 @@ impl FileFinder { #[allow(clippy::too_many_arguments)] #[pyo3(signature = ( query, + *, current_file=None, max_threads=0, page_index=0, @@ -483,6 +488,7 @@ impl FileFinder { #[allow(clippy::too_many_arguments)] #[pyo3(signature = ( query, + *, mode="plain", max_file_size=0, max_matches_per_file=0, @@ -589,6 +595,7 @@ impl FileFinder { #[allow(clippy::too_many_arguments)] #[pyo3(signature = ( patterns, + *, constraints=None, mode="plain", max_file_size=0, diff --git a/crates/fff-python/src/types.rs b/crates/fff-python/src/types.rs index da4ea310..86cb0729 100644 --- a/crates/fff-python/src/types.rs +++ b/crates/fff-python/src/types.rs @@ -324,11 +324,11 @@ impl GrepResult { self.next_file_offset > 0 } - fn next_cursor(&self, py: Python<'_>) -> Option> { + fn next_cursor(&self, py: Python<'_>) -> PyResult>> { if self.next_file_offset > 0 { - Py::new(py, GrepCursor::new(self.next_file_offset)).ok() + Ok(Some(Py::new(py, GrepCursor::new(self.next_file_offset))?)) } else { - None + Ok(None) } } } diff --git a/packages/fff-python/pyproject.toml b/packages/fff-python/pyproject.toml index 854df444..fc62711f 100644 --- a/packages/fff-python/pyproject.toml +++ b/packages/fff-python/pyproject.toml @@ -26,6 +26,7 @@ build-backend = "maturin" manifest-path = "../../crates/fff-python/Cargo.toml" module-name = "fff._fff_python" python-source = "src" +exclude = ["src/**/__pycache__/*", "src/**/*.pyc"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/packages/fff-python/src/fff/__init__.pyi b/packages/fff-python/src/fff/__init__.pyi index 7285c869..1ec24811 100644 --- a/packages/fff-python/src/fff/__init__.pyi +++ b/packages/fff-python/src/fff/__init__.pyi @@ -130,7 +130,7 @@ class ScanProgress: class GrepCursor: offset: int - def __init__(self, offset: int = ...) -> None: ... + def __init__(self, offset: int) -> None: ... def __repr__(self) -> str: ... class FileFinder: diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py index 1222262c..f9a44bc1 100644 --- a/packages/fff-python/tests/test_finder.py +++ b/packages/fff-python/tests/test_finder.py @@ -59,6 +59,30 @@ def test_pathlib_base_path(sample_dir: str) -> None: assert result.total_matched >= 1 +def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None: + with pytest.raises(TypeError): + GrepCursor() + + with pytest.raises(TypeError): + FileFinder(Path(sample_dir), None) + + with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=True) as finder: + assert finder.wait_for_scan(timeout_ms=5000) + + with pytest.raises(TypeError): + finder.search("main", None) + with pytest.raises(TypeError): + finder.glob("*.py", None) + with pytest.raises(TypeError): + finder.directory_search("src", None) + with pytest.raises(TypeError): + finder.mixed_search("src", None) + with pytest.raises(TypeError): + finder.grep("needle", "plain") + with pytest.raises(TypeError): + finder.multi_grep(["needle"], None) + + def test_create_destroy_close_and_context_manager(sample_dir: str) -> None: finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False) assert finder.wait_for_scan(timeout_ms=5000) From 73c6b9e3c767a1153c06ad49d08e4dd8742c565c Mon Sep 17 00:00:00 2001 From: 4fu Date: Tue, 16 Jun 2026 15:01:21 +0800 Subject: [PATCH 07/15] refactor(python): polish binding API and GIL handling Make the Python binding API more idiomatic before merge: replace getter-style methods with properties, keep close() as the single explicit shutdown API, add container semantics for result objects, and tighten type stubs with Literal/Sequence/PathLike support. Also reduce Rust binding duplication with shared option/result helpers and release the Python GIL around blocking filesystem, git, and query-history operations. --- crates/fff-python/src/finder.rs | 988 ++++++++++++----------- crates/fff-python/src/types.rs | 32 + packages/fff-python/README.md | 3 + packages/fff-python/examples/basic.py | 2 +- packages/fff-python/src/fff/__init__.pyi | 88 +- packages/fff-python/tests/test_finder.py | 49 +- 6 files changed, 634 insertions(+), 528 deletions(-) diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index 61c5f999..ae11ba0b 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use fff::file_picker::FilePicker; @@ -18,6 +18,117 @@ use crate::types::{ }; use crate::{parse_grep_mode, py_err}; +const DEFAULT_SEARCH_PAGE_SIZE: usize = 100; + +fn defaulted_usize(value: u32, default: usize) -> usize { + if value == 0 { default } else { value as usize } +} + +fn defaulted_u64(value: u64, default: u64) -> u64 { + if value == 0 { default } else { value } +} + +fn create_parent_dir(path: &Path) -> PyResult<()> { + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(py_err)?; + } + Ok(()) +} + +fn pagination_args(page_index: u32, page_size: u32) -> PaginationArgs { + PaginationArgs { + offset: page_index as usize, + limit: defaulted_usize(page_size, DEFAULT_SEARCH_PAGE_SIZE), + } +} + +fn fuzzy_options<'a>( + max_threads: u32, + current_file: Option<&'a str>, + project_path: &'a Path, + page_index: u32, + page_size: u32, + combo_boost_score_multiplier: i32, + min_combo_count: u32, +) -> FuzzySearchOptions<'a> { + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file, + project_path: Some(project_path), + combo_boost_score_multiplier, + min_combo_count, + pagination: pagination_args(page_index, page_size), + } +} + +#[allow(clippy::too_many_arguments)] +fn grep_options( + mode: fff::GrepMode, + cursor_offset: usize, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + page_limit: u32, + time_budget_ms: u64, + before_context: u32, + after_context: u32, + classify_definitions: bool, +) -> GrepSearchOptions { + let defaults = GrepSearchOptions::default(); + GrepSearchOptions { + max_file_size: defaulted_u64(max_file_size, defaults.max_file_size), + max_matches_per_file: max_matches_per_file as usize, + smart_case, + file_offset: cursor_offset, + page_limit: defaulted_usize(page_limit, defaults.page_limit), + mode, + time_budget_ms, + before_context: before_context as usize, + after_context: after_context as usize, + classify_definitions, + trim_whitespace: false, + abort_signal: None, + } +} + +fn convert_scores(scores: &[fff::Score]) -> Vec { + scores.iter().map(Score::from).collect() +} + +fn convert_grep_result(result: fff::grep::GrepResult<'_>, picker: &FilePicker) -> GrepResult { + let items = result + .matches + .iter() + .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) + .collect(); + + GrepResult { + items, + total_matched: result.matches.len() as u32, + total_files_searched: result.total_files_searched as u32, + total_files: result.total_files as u32, + filtered_file_count: result.filtered_file_count as u32, + next_file_offset: result.next_file_offset as u32, + regex_fallback_error: result.regex_fallback_error, + } +} + +fn clear_shared_state( + picker: &SharedFilePicker, + frecency: &SharedFrecency, + query_tracker: &SharedQueryTracker, +) { + if let Ok(mut guard) = picker.write() { + guard.take(); + } + if let Ok(mut guard) = frecency.write() { + *guard = None; + } + if let Ok(mut guard) = query_tracker.write() { + *guard = None; + } +} + #[pyclass] pub struct FileFinder { picker: SharedFilePicker, @@ -30,15 +141,7 @@ pub struct FileFinder { impl Drop for FileFinder { fn drop(&mut self) { - if let Ok(mut guard) = self.picker.write() { - guard.take(); - } - if let Ok(mut guard) = self.frecency.write() { - *guard = None; - } - if let Ok(mut guard) = self.query_tracker.write() { - *guard = None; - } + clear_shared_state(&self.picker, &self.frecency, &self.query_tracker); } } @@ -64,14 +167,15 @@ impl FileFinder { ))] #[allow(clippy::too_many_arguments)] fn new( + py: Python<'_>, base_path: PathBuf, - frecency_db_path: Option, - history_db_path: Option, + frecency_db_path: Option, + history_db_path: Option, enable_mmap_cache: bool, enable_content_indexing: bool, watch: bool, ai_mode: bool, - log_file_path: Option, + log_file_path: Option, log_level: Option, cache_budget_max_files: u64, cache_budget_max_bytes: u64, @@ -82,64 +186,64 @@ impl FileFinder { let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); let query_tracker = SharedQueryTracker::default(); + let cache_budget_max_files = cache_budget_max_files as usize; + + let init_picker = shared_picker.clone(); + let init_frecency = shared_frecency.clone(); + let init_query_tracker = query_tracker.clone(); - if let Some(path) = frecency_db_path { - let parent = PathBuf::from(&path).parent().map(PathBuf::from); - if let Some(p) = parent { - let _ = std::fs::create_dir_all(p); + py.allow_threads(move || -> PyResult<()> { + if let Some(path) = frecency_db_path { + create_parent_dir(&path)?; + let tracker = FrecencyTracker::open(&path).map_err(py_err)?; + init_frecency.init(tracker).map_err(py_err)?; } - let tracker = FrecencyTracker::open(&path).map_err(py_err)?; - shared_frecency.init(tracker).map_err(py_err)?; - } - if let Some(path) = history_db_path { - let parent = PathBuf::from(&path).parent().map(PathBuf::from); - if let Some(p) = parent { - let _ = std::fs::create_dir_all(p); + if let Some(path) = history_db_path { + create_parent_dir(&path)?; + let tracker = QueryTracker::open(&path).map_err(py_err)?; + init_query_tracker.init(tracker).map_err(py_err)?; } - let tracker = QueryTracker::open(&path).map_err(py_err)?; - query_tracker.init(tracker).map_err(py_err)?; - } - if let Some(path) = log_file_path { - let level = log_level.as_deref(); - fff::log::init_tracing(&path, level, None).map_err(py_err)?; - } + if let Some(path) = log_file_path { + create_parent_dir(&path)?; + let path = path.to_string_lossy(); + fff::log::init_tracing(&path, log_level.as_deref(), None).map_err(py_err)?; + } - let mode = if ai_mode { - FFFMode::Ai - } else { - FFFMode::Neovim - }; + let mode = if ai_mode { + FFFMode::Ai + } else { + FFFMode::Neovim + }; - let cache_budget = fff::ContentCacheBudget::from_overrides( - cache_budget_max_files as usize, - cache_budget_max_bytes, - cache_budget_max_file_size, - ); - - FilePicker::new_with_shared_state( - shared_picker.clone(), - shared_frecency.clone(), - FilePickerOptions { - base_path: base_path.to_string_lossy().to_string(), - enable_mmap_cache, - enable_content_indexing, - watch, - mode, - cache_budget, - follow_symlinks: false, - enable_fs_root_scanning, - enable_home_dir_scanning, - }, - ) - .map_err(py_err)?; + FilePicker::new_with_shared_state( + init_picker, + init_frecency, + FilePickerOptions { + base_path: base_path.to_string_lossy().to_string(), + enable_mmap_cache, + enable_content_indexing, + watch, + mode, + cache_budget: fff::ContentCacheBudget::from_overrides( + cache_budget_max_files, + cache_budget_max_bytes, + cache_budget_max_file_size, + ), + follow_symlinks: false, + enable_fs_root_scanning, + enable_home_dir_scanning, + }, + ) + .map_err(py_err) + })?; Ok(Self { picker: shared_picker, frecency: shared_frecency, query_tracker, - cache_budget_max_files: cache_budget_max_files as usize, + cache_budget_max_files, cache_budget_max_bytes, cache_budget_max_file_size, }) @@ -150,24 +254,52 @@ impl FileFinder { } fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) { - let _ = self.destroy(); + let _ = self.close(); } - fn destroy(&mut self) -> PyResult<()> { - if let Ok(mut guard) = self.picker.write() { - *guard = None; - } - if let Ok(mut guard) = self.frecency.write() { - *guard = None; - } - if let Ok(mut guard) = self.query_tracker.write() { - *guard = None; - } + fn close(&mut self) -> PyResult<()> { + clear_shared_state(&self.picker, &self.frecency, &self.query_tracker); Ok(()) } - fn close(&mut self) -> PyResult<()> { - self.destroy() + #[getter] + fn closed(&self) -> PyResult { + Ok(self.picker.read().map_err(py_err)?.is_none()) + } + + #[getter] + fn base_path(&self) -> PyResult> { + let guard = self.picker.read().map_err(py_err)?; + Ok(guard + .as_ref() + .map(|p| p.base_path().to_string_lossy().to_string())) + } + + #[getter] + fn scan_progress(&self) -> PyResult { + let guard = self.picker.read().map_err(py_err)?; + let picker = guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let p = picker.get_scan_progress(); + Ok(ScanProgress { + scanned_files_count: p.scanned_files_count as u64, + is_scanning: p.is_scanning, + is_watcher_ready: p.is_watcher_ready, + is_warmup_complete: p.is_warmup_complete, + }) + } + + fn __repr__(&self) -> PyResult { + let guard = self.picker.read().map_err(py_err)?; + if let Some(ref picker) = *guard { + Ok(format!( + "FileFinder(base_path={:?}, closed=False)", + picker.base_path().to_string_lossy() + )) + } else { + Ok("FileFinder(closed=True)".to_string()) + } } #[allow(clippy::too_many_arguments)] @@ -196,56 +328,38 @@ impl FileFinder { let query_tracker = self.query_tracker.clone(); let query = query.to_string(); - let (items, scores, total_matched, total_files) = - py.allow_threads(move || -> PyResult<_> { - let picker_guard = picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - let qt_guard = query_tracker.read().map_err(py_err)?; - - let parser = QueryParser::default(); - let parsed = parser.parse(&query); - let result = picker.fuzzy_search( - &parsed, - qt_guard.as_ref(), - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier, - min_combo_count, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize - }, - }, - }, - ); + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let qt_guard = query_tracker.read().map_err(py_err)?; + + let parsed = QueryParser::default().parse(&query); + let result = picker.fuzzy_search( + &parsed, + qt_guard.as_ref(), + fuzzy_options( + max_threads, + current_file.as_deref(), + picker.base_path(), + page_index, + page_size, + combo_boost_score_multiplier, + min_combo_count, + ), + ); - let items: Vec = result + Ok(SearchResult { + items: result .items .iter() .map(|i| FileItem::from_core(i, picker)) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); - - Ok(( - items, - scores, - result.total_matched as u32, - result.total_files as u32, - )) - })?; - - Ok(SearchResult { - items, - scores, - total_matched, - total_files, + .collect(), + scores: convert_scores(&result.scores), + total_matched: result.total_matched as u32, + total_files: result.total_files as u32, + }) }) } @@ -269,52 +383,35 @@ impl FileFinder { let picker = self.picker.clone(); let pattern = pattern.to_string(); - let (items, scores, total_matched, total_files) = - py.allow_threads(move || -> PyResult<_> { - let picker_guard = picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; - let result = picker.glob( - &pattern, - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier: 0, - min_combo_count: 0, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize - }, - }, - }, - ); + let result = picker.glob( + &pattern, + fuzzy_options( + max_threads, + current_file.as_deref(), + picker.base_path(), + page_index, + page_size, + 0, + 0, + ), + ); - let items: Vec = result + Ok(SearchResult { + items: result .items .iter() .map(|i| FileItem::from_core(i, picker)) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); - - Ok(( - items, - scores, - result.total_matched as u32, - result.total_files as u32, - )) - })?; - - Ok(SearchResult { - items, - scores, - total_matched, - total_files, + .collect(), + scores: convert_scores(&result.scores), + total_matched: result.total_matched as u32, + total_files: result.total_files as u32, + }) }) } @@ -338,54 +435,36 @@ impl FileFinder { let picker = self.picker.clone(); let query = query.to_string(); - let (items, scores, total_matched, total_dirs) = - py.allow_threads(move || -> PyResult<_> { - let picker_guard = picker.read().map_err(py_err)?; - let picker = picker_guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; - let parser = QueryParser::new(fff_query_parser::DirSearchConfig); - let parsed = parser.parse(&query); - let result = picker.fuzzy_search_directories( - &parsed, - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), - combo_boost_score_multiplier: 0, - min_combo_count: 0, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize - }, - }, - }, - ); + let parsed = QueryParser::new(fff_query_parser::DirSearchConfig).parse(&query); + let result = picker.fuzzy_search_directories( + &parsed, + fuzzy_options( + max_threads, + current_file.as_deref(), + picker.base_path(), + page_index, + page_size, + 0, + 0, + ), + ); - let items: Vec = result + Ok(DirSearchResult { + items: result .items .iter() .map(|i| DirItem::from_core(i, picker)) - .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); - - Ok(( - items, - scores, - result.total_matched as u32, - result.total_dirs as u32, - )) - })?; - - Ok(DirSearchResult { - items, - scores, - total_matched, - total_dirs, + .collect(), + scores: convert_scores(&result.scores), + total_matched: result.total_matched as u32, + total_dirs: result.total_dirs as u32, + }) }) } @@ -423,26 +502,19 @@ impl FileFinder { .ok_or_else(|| py_err("File picker not initialized"))?; let qt_guard = query_tracker.read().map_err(py_err)?; - let parser = QueryParser::new(fff_query_parser::MixedSearchConfig); - let parsed = parser.parse(&query); + let parsed = QueryParser::new(fff_query_parser::MixedSearchConfig).parse(&query); let result = picker.fuzzy_search_mixed( &parsed, qt_guard.as_ref(), - FuzzySearchOptions { - max_threads: max_threads as usize, - current_file: current_file.as_deref(), - project_path: Some(picker.base_path()), + fuzzy_options( + max_threads, + current_file.as_deref(), + picker.base_path(), + page_index, + page_size, combo_boost_score_multiplier, min_combo_count, - pagination: PaginationArgs { - offset: page_index as usize, - limit: if page_size == 0 { - 100 - } else { - page_size as usize - }, - }, - }, + ), ); let items: Vec = result @@ -457,11 +529,10 @@ impl FileFinder { } }) .collect(); - let scores: Vec = result.scores.iter().map(Score::from).collect(); Ok(( items, - scores, + convert_scores(&result.scores), result.total_matched as u32, result.total_files as u32, result.total_dirs as u32, @@ -518,77 +589,33 @@ impl FileFinder { let picker = self.picker.clone(); let query = query.to_string(); let mode = parse_grep_mode(mode)?; + let cursor_offset = cursor.map(|c| c.offset as usize).unwrap_or(0); - let ( - items, - total_matched, - total_files_searched, - total_files, - filtered_file_count, - next_file_offset, - regex_fallback_error, - ) = py.allow_threads(move || -> PyResult<_> { + py.allow_threads(move || -> PyResult<_> { let picker_guard = picker.read().map_err(py_err)?; let picker = picker_guard .as_ref() .ok_or_else(|| py_err("File picker not initialized"))?; - let is_ai = picker.mode().is_ai(); - let parsed = if is_ai { + let parsed = if picker.mode().is_ai() { QueryParser::new(fff_query_parser::AiGrepConfig).parse(&query) } else { fff::grep::parse_grep_query(&query) }; - - let options = GrepSearchOptions { - max_file_size: if max_file_size == 0 { - 10 * 1024 * 1024 - } else { - max_file_size - }, - max_matches_per_file: max_matches_per_file as usize, - smart_case, - file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), - page_limit: if page_limit == 0 { - 50 - } else { - page_limit as usize - }, + let options = grep_options( mode, + cursor_offset, + max_file_size, + max_matches_per_file, + smart_case, + page_limit, time_budget_ms, - before_context: before_context as usize, - after_context: after_context as usize, + before_context, + after_context, classify_definitions, - trim_whitespace: false, - abort_signal: None, - }; - - let result = picker.grep(&parsed, &options); - let items: Vec = result - .matches - .iter() - .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) - .collect(); - - Ok(( - items, - result.matches.len() as u32, - result.total_files_searched as u32, - result.total_files as u32, - result.filtered_file_count as u32, - result.next_file_offset as u32, - result.regex_fallback_error, - )) - })?; + ); - Ok(GrepResult { - items, - total_matched, - total_files_searched, - total_files, - filtered_file_count, - next_file_offset, - regex_fallback_error, + Ok(convert_grep_result(picker.grep(&parsed, &options), picker)) }) } @@ -626,16 +653,9 @@ impl FileFinder { ) -> PyResult { let picker = self.picker.clone(); let mode = parse_grep_mode(mode)?; + let cursor_offset = cursor.map(|c| c.offset as usize).unwrap_or(0); - let ( - items, - total_matched, - total_files_searched, - total_files, - filtered_file_count, - next_file_offset, - regex_fallback_error, - ) = py.allow_threads(move || -> PyResult<_> { + py.allow_threads(move || -> PyResult<_> { let picker_guard = picker.read().map_err(py_err)?; let picker = picker_guard .as_ref() @@ -646,9 +666,8 @@ impl FileFinder { } let pattern_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect(); - let is_ai = picker.mode().is_ai(); let parsed_constraints = constraints.as_ref().map(|c| { - if is_ai { + if picker.mode().is_ai() { QueryParser::new(fff_query_parser::AiGrepConfig).parse(c) } else { fff::grep::parse_grep_query(c) @@ -658,56 +677,23 @@ impl FileFinder { Some(q) => &q.constraints, None => &[], }; - - let options = GrepSearchOptions { - max_file_size: if max_file_size == 0 { - 10 * 1024 * 1024 - } else { - max_file_size - }, - max_matches_per_file: max_matches_per_file as usize, - smart_case, - file_offset: cursor.map(|c| c.offset as usize).unwrap_or(0), - page_limit: if page_limit == 0 { - 50 - } else { - page_limit as usize - }, + let options = grep_options( mode, + cursor_offset, + max_file_size, + max_matches_per_file, + smart_case, + page_limit, time_budget_ms, - before_context: before_context as usize, - after_context: after_context as usize, + before_context, + after_context, classify_definitions, - trim_whitespace: false, - abort_signal: None, - }; + ); - let result = picker.multi_grep(&pattern_refs, constraint_refs, &options); - let items: Vec = result - .matches - .iter() - .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) - .collect(); - - Ok(( - items, - result.matches.len() as u32, - result.total_files_searched as u32, - result.total_files as u32, - result.filtered_file_count as u32, - result.next_file_offset as u32, - result.regex_fallback_error, + Ok(convert_grep_result( + picker.multi_grep(&pattern_refs, constraint_refs, &options), + picker, )) - })?; - - Ok(GrepResult { - items, - total_matched, - total_files_searched, - total_files, - filtered_file_count, - next_file_offset, - regex_fallback_error, }) } @@ -722,185 +708,233 @@ impl FileFinder { Ok(guard.as_ref().map(|p| p.is_scan_active()).unwrap_or(false)) } - fn wait_for_scan(&self, timeout_ms: u64) -> PyResult { - Ok(self.picker.wait_for_scan(Duration::from_millis(timeout_ms))) + fn wait_for_scan(&self, py: Python<'_>, timeout_ms: u64) -> PyResult { + let picker = self.picker.clone(); + py.allow_threads(move || Ok(picker.wait_for_scan(Duration::from_millis(timeout_ms)))) } - fn get_scan_progress(&self) -> PyResult { - let guard = self.picker.read().map_err(py_err)?; - let picker = guard - .as_ref() - .ok_or_else(|| py_err("File picker not initialized"))?; - let p = picker.get_scan_progress(); - Ok(ScanProgress { - scanned_files_count: p.scanned_files_count as u64, - is_scanning: p.is_scanning, - is_watcher_ready: p.is_watcher_ready, - is_warmup_complete: p.is_warmup_complete, + fn reindex(&self, py: Python<'_>, new_path: PathBuf) -> PyResult<()> { + let picker = self.picker.clone(); + let frecency = self.frecency.clone(); + let cache_budget_max_files = self.cache_budget_max_files; + let cache_budget_max_bytes = self.cache_budget_max_bytes; + let cache_budget_max_file_size = self.cache_budget_max_file_size; + + py.allow_threads(move || -> PyResult<()> { + if !new_path.exists() { + return Err(py_err(format!( + "Path does not exist: {}", + new_path.display() + ))); + } + let canonical = fff::path_utils::canonicalize(&new_path).map_err(py_err)?; + + let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir) = { + let guard = picker.read().map_err(py_err)?; + if let Some(ref picker) = *guard { + ( + picker.has_mmap_cache(), + picker.has_content_indexing(), + picker.has_watcher(), + picker.mode(), + picker.fs_root_scanning_enabled(), + picker.home_dir_scanning_enabled(), + ) + } else { + (false, true, true, FFFMode::default(), false, false) + } + }; + + FilePicker::new_with_shared_state( + picker.clone(), + frecency, + FilePickerOptions { + base_path: canonical.to_string_lossy().to_string(), + enable_mmap_cache: warmup_caches, + enable_content_indexing: content_indexing, + watch, + mode, + cache_budget: fff::ContentCacheBudget::from_overrides( + cache_budget_max_files, + cache_budget_max_bytes, + cache_budget_max_file_size, + ), + follow_symlinks: false, + enable_fs_root_scanning: fs_root, + enable_home_dir_scanning: home_dir, + }, + ) + .map_err(py_err) }) } - fn get_base_path(&self) -> PyResult> { - let guard = self.picker.read().map_err(py_err)?; - Ok(guard - .as_ref() - .map(|p| p.base_path().to_string_lossy().to_string())) + fn refresh_git_status(&self, py: Python<'_>) -> PyResult { + let picker = self.picker.clone(); + let frecency = self.frecency.clone(); + py.allow_threads(move || { + picker + .refresh_git_status(&frecency) + .map_err(py_err) + .map(|c| c as i64) + }) } - fn reindex(&self, new_path: PathBuf) -> PyResult<()> { - if !new_path.exists() { - return Err(py_err(format!( - "Path does not exist: {}", - new_path.display() - ))); - } - let canonical = fff::path_utils::canonicalize(&new_path).map_err(py_err)?; - - let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir) = { - let guard = self.picker.write().map_err(py_err)?; - if let Some(ref picker) = *guard { - ( - picker.has_mmap_cache(), - picker.has_content_indexing(), - picker.has_watcher(), - picker.mode(), - picker.fs_root_scanning_enabled(), - picker.home_dir_scanning_enabled(), - ) + #[pyo3(signature = (query, selected_file_path))] + fn track_query( + &self, + py: Python<'_>, + query: &str, + selected_file_path: PathBuf, + ) -> PyResult { + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); + let query = query.to_string(); + + py.allow_threads(move || -> PyResult { + let file_path = fff::path_utils::canonicalize(&selected_file_path).map_err(py_err)?; + let project_path = { + let guard = picker.read().map_err(py_err)?; + guard.as_ref().map(|p| p.base_path().to_path_buf()) + }; + let project_path = match project_path { + Some(p) => p, + None => return Ok(false), + }; + + let mut qt_guard = query_tracker.write().map_err(py_err)?; + if let Some(ref mut tracker) = *qt_guard { + tracker + .track_query_completion(&query, &project_path, &file_path) + .map_err(py_err)?; + Ok(true) } else { - (false, true, true, FFFMode::default(), false, false) + Ok(false) } - }; - - FilePicker::new_with_shared_state( - self.picker.clone(), - self.frecency.clone(), - FilePickerOptions { - base_path: canonical.to_string_lossy().to_string(), - enable_mmap_cache: warmup_caches, - enable_content_indexing: content_indexing, - watch, - mode, - cache_budget: fff::ContentCacheBudget::from_overrides( - self.cache_budget_max_files, - self.cache_budget_max_bytes, - self.cache_budget_max_file_size, - ), - follow_symlinks: false, - enable_fs_root_scanning: fs_root, - enable_home_dir_scanning: home_dir, - }, - ) - .map_err(py_err) + }) } - fn refresh_git_status(&self) -> PyResult { - self.picker - .refresh_git_status(&self.frecency) - .map_err(py_err) - .map(|c| c as i64) - } + fn get_historical_query(&self, py: Python<'_>, offset: u64) -> PyResult> { + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); - #[pyo3(signature = (query, selected_file_path))] - fn track_query(&self, query: &str, selected_file_path: &str) -> PyResult { - let file_path = fff::path_utils::canonicalize(selected_file_path).map_err(py_err)?; - let project_path = { - let guard = self.picker.read().map_err(py_err)?; - guard.as_ref().map(|p| p.base_path().to_path_buf()) - }; - let project_path = match project_path { - Some(p) => p, - None => return Ok(false), - }; - - let mut qt_guard = self.query_tracker.write().map_err(py_err)?; - if let Some(ref mut tracker) = *qt_guard { - tracker - .track_query_completion(query, &project_path, &file_path) - .map_err(py_err)?; - Ok(true) - } else { - Ok(false) - } - } + py.allow_threads(move || -> PyResult> { + let project_path = { + let guard = picker.read().map_err(py_err)?; + guard.as_ref().map(|p| p.base_path().to_path_buf()) + }; + let project_path = match project_path { + Some(p) => p, + None => return Ok(None), + }; - fn get_historical_query(&self, offset: u64) -> PyResult> { - let project_path = { - let guard = self.picker.read().map_err(py_err)?; - guard.as_ref().map(|p| p.base_path().to_path_buf()) - }; - let project_path = match project_path { - Some(p) => p, - None => return Ok(None), - }; - - let qt_guard = self.query_tracker.read().map_err(py_err)?; - if let Some(ref tracker) = *qt_guard { - tracker - .get_historical_query(&project_path, offset as usize) - .map_err(py_err) - } else { - Ok(None) - } + let qt_guard = query_tracker.read().map_err(py_err)?; + if let Some(ref tracker) = *qt_guard { + tracker + .get_historical_query(&project_path, offset as usize) + .map_err(py_err) + } else { + Ok(None) + } + }) } #[pyo3(signature = (test_path=None))] fn health_check(&self, py: Python<'_>, test_path: Option) -> PyResult> { let test_path = test_path.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let picker = self.picker.clone(); + let frecency = self.frecency.clone(); + let query_tracker = self.query_tracker.clone(); + + let ( + git_version, + repository_found, + workdir, + git_error, + picker_initialized, + picker_base_path, + picker_is_scanning, + picker_indexed_files, + frecency_initialized, + query_tracker_initialized, + ) = py.allow_threads(move || -> PyResult<_> { + let git_version = git2::Version::get(); + let (major, minor, rev) = git_version.libgit2_version(); + let git_version = format!("{}.{}.{}", major, minor, rev); + let (repository_found, workdir, git_error) = + match git2::Repository::discover(&test_path) { + Ok(repo) => ( + true, + repo.workdir().map(|p| p.to_string_lossy().to_string()), + None, + ), + Err(e) => (false, None, Some(e.message().to_string())), + }; + + let (picker_initialized, picker_base_path, picker_is_scanning, picker_indexed_files) = { + let guard = picker.read().map_err(py_err)?; + if let Some(ref picker) = *guard { + let progress = picker.get_scan_progress(); + ( + true, + Some(picker.base_path().to_string_lossy().to_string()), + Some(picker.is_scan_active()), + Some(progress.scanned_files_count), + ) + } else { + (false, None, None, None) + } + }; + let frecency_initialized = frecency.read().map_err(py_err)?.is_some(); + let query_tracker_initialized = query_tracker.read().map_err(py_err)?.is_some(); + + Ok(( + git_version, + repository_found, + workdir, + git_error, + picker_initialized, + picker_base_path, + picker_is_scanning, + picker_indexed_files, + frecency_initialized, + query_tracker_initialized, + )) + })?; let dict = PyDict::new(py); dict.set_item("version", env!("CARGO_PKG_VERSION"))?; let git_info = PyDict::new(py); - let git_version = git2::Version::get(); - let (major, minor, rev) = git_version.libgit2_version(); - git_info.set_item("libgit2_version", format!("{}.{}.{}", major, minor, rev))?; - match git2::Repository::discover(&test_path) { - Ok(repo) => { - git_info.set_item("available", true)?; - git_info.set_item("repository_found", true)?; - if let Some(workdir) = repo.workdir() { - git_info.set_item("workdir", workdir.to_string_lossy().to_string())?; - } - } - Err(e) => { - git_info.set_item("available", true)?; - git_info.set_item("repository_found", false)?; - git_info.set_item("error", e.message().to_string())?; - } + git_info.set_item("available", true)?; + git_info.set_item("libgit2_version", git_version)?; + git_info.set_item("repository_found", repository_found)?; + if let Some(workdir) = workdir { + git_info.set_item("workdir", workdir)?; + } + if let Some(error) = git_error { + git_info.set_item("error", error)?; } dict.set_item("git", git_info)?; let picker_info = PyDict::new(py); - { - let guard = self.picker.read().map_err(py_err)?; - if let Some(ref picker) = *guard { - picker_info.set_item("initialized", true)?; - picker_info.set_item( - "base_path", - picker.base_path().to_string_lossy().to_string(), - )?; - picker_info.set_item("is_scanning", picker.is_scan_active())?; - let progress = picker.get_scan_progress(); - picker_info.set_item("indexed_files", progress.scanned_files_count)?; - } else { - picker_info.set_item("initialized", false)?; - } + picker_info.set_item("initialized", picker_initialized)?; + if let Some(base_path) = picker_base_path { + picker_info.set_item("base_path", base_path)?; + } + if let Some(is_scanning) = picker_is_scanning { + picker_info.set_item("is_scanning", is_scanning)?; + } + if let Some(indexed_files) = picker_indexed_files { + picker_info.set_item("indexed_files", indexed_files)?; } dict.set_item("file_picker", picker_info)?; let frecency_info = PyDict::new(py); - { - let guard = self.frecency.read().map_err(py_err)?; - frecency_info.set_item("initialized", guard.is_some())?; - } + frecency_info.set_item("initialized", frecency_initialized)?; dict.set_item("frecency", frecency_info)?; let query_info = PyDict::new(py); - { - let guard = self.query_tracker.read().map_err(py_err)?; - query_info.set_item("initialized", guard.is_some())?; - } + query_info.set_item("initialized", query_tracker_initialized)?; dict.set_item("query_tracker", query_info)?; Ok(dict.unbind()) diff --git a/crates/fff-python/src/types.rs b/crates/fff-python/src/types.rs index 86cb0729..187e0b49 100644 --- a/crates/fff-python/src/types.rs +++ b/crates/fff-python/src/types.rs @@ -235,6 +235,14 @@ impl SearchResult { self.total_files ) } + + fn __len__(&self) -> usize { + self.items.len() + } + + fn __bool__(&self) -> bool { + !self.items.is_empty() + } } #[pyclass] @@ -260,6 +268,14 @@ impl DirSearchResult { self.total_dirs ) } + + fn __len__(&self) -> usize { + self.items.len() + } + + fn __bool__(&self) -> bool { + !self.items.is_empty() + } } #[pyclass] @@ -287,6 +303,14 @@ impl MixedSearchResult { self.total_dirs ) } + + fn __len__(&self) -> usize { + self.items.len() + } + + fn __bool__(&self) -> bool { + !self.items.is_empty() + } } #[pyclass] @@ -319,6 +343,14 @@ impl GrepResult { ) } + fn __len__(&self) -> usize { + self.items.len() + } + + fn __bool__(&self) -> bool { + !self.items.is_empty() + } + #[getter] fn has_more(&self) -> bool { self.next_file_offset > 0 diff --git a/packages/fff-python/README.md b/packages/fff-python/README.md index cf8da9a4..b2bd4666 100644 --- a/packages/fff-python/README.md +++ b/packages/fff-python/README.md @@ -37,8 +37,11 @@ from fff import FileFinder with FileFinder("/path/to/project", watch=False) as finder: finder.wait_for_scan(timeout_ms=5000) + print(f"Indexed under {finder.base_path}") result = finder.search("main") + if result: + print(f"Showing {len(result)} of {result.total_matched} matches") for item, score in zip(result.items, result.scores): print(f"{item.relative_path}: {score.total}") ``` diff --git a/packages/fff-python/examples/basic.py b/packages/fff-python/examples/basic.py index 89275d8f..a975dbf5 100644 --- a/packages/fff-python/examples/basic.py +++ b/packages/fff-python/examples/basic.py @@ -18,7 +18,7 @@ def main() -> int: print("Waiting for scan...") finder.wait_for_scan(timeout_ms=30000) - progress = finder.get_scan_progress() + progress = finder.scan_progress print(f"Indexed {progress.scanned_files_count} files") print("\nFuzzy file search for 'main':") diff --git a/packages/fff-python/src/fff/__init__.pyi b/packages/fff-python/src/fff/__init__.pyi index 1ec24811..cc7a2e45 100644 --- a/packages/fff-python/src/fff/__init__.pyi +++ b/packages/fff-python/src/fff/__init__.pyi @@ -2,8 +2,12 @@ from __future__ import annotations +from collections.abc import Sequence from os import PathLike -from typing import Any, Dict, List, Optional, Union +from typing import Any, Literal, TypeAlias + +_PathInput: TypeAlias = str | PathLike[str] +_GrepMode: TypeAlias = Literal["plain", "regex", "fuzzy"] __version__: str @@ -70,9 +74,9 @@ class GrepMatch: file_name: str git_status: str line_content: str - match_ranges: List[MatchRange] - context_before: List[str] - context_after: List[str] + match_ranges: list[MatchRange] + context_before: list[str] + context_after: list[str] size: int modified: int total_frecency_score: int @@ -81,44 +85,52 @@ class GrepMatch: line_number: int byte_offset: int col: int - fuzzy_score: Optional[int] + fuzzy_score: int | None is_definition: bool is_binary: bool def __repr__(self) -> str: ... class SearchResult: - items: List[FileItem] - scores: List[Score] + items: list[FileItem] + scores: list[Score] total_matched: int total_files: int + def __len__(self) -> int: ... + def __bool__(self) -> bool: ... def __repr__(self) -> str: ... class DirSearchResult: - items: List[DirItem] - scores: List[Score] + items: list[DirItem] + scores: list[Score] total_matched: int total_dirs: int + def __len__(self) -> int: ... + def __bool__(self) -> bool: ... def __repr__(self) -> str: ... class MixedSearchResult: - items: List[Union[MixedFileItem, MixedDirItem]] - scores: List[Score] + items: list[MixedFileItem | MixedDirItem] + scores: list[Score] total_matched: int total_files: int total_dirs: int + def __len__(self) -> int: ... + def __bool__(self) -> bool: ... def __repr__(self) -> str: ... class GrepResult: - items: List[GrepMatch] + items: list[GrepMatch] total_matched: int total_files_searched: int total_files: int filtered_file_count: int next_file_offset: int - regex_fallback_error: Optional[str] + regex_fallback_error: str | None + def __len__(self) -> int: ... + def __bool__(self) -> bool: ... @property def has_more(self) -> bool: ... - def next_cursor(self) -> Optional[GrepCursor]: ... + def next_cursor(self) -> GrepCursor | None: ... def __repr__(self) -> str: ... class ScanProgress: @@ -136,16 +148,16 @@ class GrepCursor: class FileFinder: def __init__( self, - base_path: Union[str, PathLike[str]], + base_path: _PathInput, *, - frecency_db_path: Optional[str] = None, - history_db_path: Optional[str] = None, + frecency_db_path: _PathInput | None = None, + history_db_path: _PathInput | None = None, enable_mmap_cache: bool = True, enable_content_indexing: bool = True, watch: bool = True, ai_mode: bool = False, - log_file_path: Optional[str] = None, - log_level: Optional[str] = None, + log_file_path: _PathInput | None = None, + log_level: str | None = None, cache_budget_max_files: int = 0, cache_budget_max_bytes: int = 0, cache_budget_max_file_size: int = 0, @@ -154,13 +166,19 @@ class FileFinder: ) -> None: ... def __enter__(self) -> FileFinder: ... def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ... - def destroy(self) -> None: ... + def __repr__(self) -> str: ... + @property + def closed(self) -> bool: ... + @property + def base_path(self) -> str | None: ... + @property + def scan_progress(self) -> ScanProgress: ... def close(self) -> None: ... def search( self, query: str, *, - current_file: Optional[str] = None, + current_file: str | None = None, max_threads: int = 0, page_index: int = 0, page_size: int = 0, @@ -171,7 +189,7 @@ class FileFinder: self, pattern: str, *, - current_file: Optional[str] = None, + current_file: str | None = None, max_threads: int = 0, page_index: int = 0, page_size: int = 0, @@ -180,7 +198,7 @@ class FileFinder: self, query: str, *, - current_file: Optional[str] = None, + current_file: str | None = None, max_threads: int = 0, page_index: int = 0, page_size: int = 0, @@ -189,7 +207,7 @@ class FileFinder: self, query: str, *, - current_file: Optional[str] = None, + current_file: str | None = None, max_threads: int = 0, page_index: int = 0, page_size: int = 0, @@ -200,11 +218,11 @@ class FileFinder: self, query: str, *, - mode: str = "plain", + mode: _GrepMode = "plain", max_file_size: int = 0, max_matches_per_file: int = 0, smart_case: bool = True, - cursor: Optional[GrepCursor] = None, + cursor: GrepCursor | None = None, page_limit: int = 0, time_budget_ms: int = 0, before_context: int = 0, @@ -213,14 +231,14 @@ class FileFinder: ) -> GrepResult: ... def multi_grep( self, - patterns: List[str], + patterns: Sequence[str], *, - constraints: Optional[str] = None, - mode: str = "plain", + constraints: str | None = None, + mode: _GrepMode = "plain", max_file_size: int = 0, max_matches_per_file: int = 0, smart_case: bool = True, - cursor: Optional[GrepCursor] = None, + cursor: GrepCursor | None = None, page_limit: int = 0, time_budget_ms: int = 0, before_context: int = 0, @@ -230,10 +248,8 @@ class FileFinder: def scan_files(self) -> None: ... def is_scanning(self) -> bool: ... def wait_for_scan(self, timeout_ms: int) -> bool: ... - def get_scan_progress(self) -> ScanProgress: ... - def get_base_path(self) -> Optional[str]: ... - def reindex(self, new_path: Union[str, PathLike[str]]) -> None: ... + def reindex(self, new_path: _PathInput) -> None: ... def refresh_git_status(self) -> int: ... - def track_query(self, query: str, selected_file_path: str) -> bool: ... - def get_historical_query(self, offset: int) -> Optional[str]: ... - def health_check(self, test_path: Optional[Union[str, PathLike[str]]] = None) -> Dict[str, Any]: ... + def track_query(self, query: str, selected_file_path: _PathInput) -> bool: ... + def get_historical_query(self, offset: int) -> str | None: ... + def health_check(self, test_path: _PathInput | None = None) -> dict[str, Any]: ... diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py index f9a44bc1..8ae6bb56 100644 --- a/packages/fff-python/tests/test_finder.py +++ b/packages/fff-python/tests/test_finder.py @@ -54,7 +54,9 @@ def test_imports_and_package_version() -> None: def test_pathlib_base_path(sample_dir: str) -> None: with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan(timeout_ms=5000) - assert finder.get_base_path() is not None + assert finder.closed is False + assert finder.base_path is not None + assert finder.scan_progress.scanned_files_count >= 1 result = finder.search("main") assert result.total_matched >= 1 @@ -83,24 +85,29 @@ def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None: finder.multi_grep(["needle"], None) -def test_create_destroy_close_and_context_manager(sample_dir: str) -> None: +def test_close_and_context_manager(sample_dir: str) -> None: finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False) assert finder.wait_for_scan(timeout_ms=5000) - assert finder.get_base_path() is not None - finder.destroy() + assert finder.closed is False + assert finder.base_path is not None + finder.close() + assert finder.closed is True with pytest.raises(FFFException, match="File picker not initialized"): finder.search("main") with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as ctx_finder: assert ctx_finder.wait_for_scan(timeout_ms=5000) + assert ctx_finder.closed is False + assert ctx_finder.closed is True with pytest.raises(FFFException, match="File picker not initialized"): ctx_finder.search("main") fresh = FileFinder(sample_dir, watch=False, enable_content_indexing=False) assert fresh.wait_for_scan(timeout_ms=5000) fresh.close() + assert fresh.closed is True with pytest.raises(FFFException, match="File picker not initialized"): fresh.search("main") @@ -108,6 +115,7 @@ def test_create_destroy_close_and_context_manager(sample_dir: str) -> None: def test_reprs(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: assert finder.wait_for_scan(timeout_ms=5000) + assert repr(finder).startswith("FileFinder(") result = finder.search("main") assert repr(result).startswith("SearchResult(") assert repr(result.items[0]).startswith("FileItem(") @@ -128,7 +136,7 @@ def test_reprs(sample_dir: str) -> None: cursor = GrepCursor(42) assert repr(cursor) == "GrepCursor(offset=42)" - progress = finder.get_scan_progress() + progress = finder.scan_progress assert repr(progress).startswith("ScanProgress(") @@ -137,7 +145,8 @@ def test_file_search_scores_and_pagination(sample_dir: str) -> None: assert finder.wait_for_scan(timeout_ms=5000) result = finder.search("main", page_size=1) assert result.total_matched >= 1 - assert len(result.items) == 1 + assert len(result) == len(result.items) == 1 + assert bool(result) is True assert any("main.py" in rel(item.relative_path) for item in result.items) score = result.scores[0] @@ -146,7 +155,11 @@ def test_file_search_scores_and_pagination(sample_dir: str) -> None: assert isinstance(score.match_type, str) second_page = finder.search("", page_index=1, page_size=1) - assert len(second_page.items) == 1 + assert len(second_page) == len(second_page.items) == 1 + + empty = finder.search("definitely_no_such_file_xyz") + assert len(empty) == 0 + assert bool(empty) is False def test_glob_variants(sample_dir: str) -> None: @@ -173,10 +186,14 @@ def test_directory_and_mixed_search(sample_dir: str) -> None: dirs = finder.directory_search("src") assert dirs.total_matched >= 1 + assert len(dirs) == len(dirs.items) + assert bool(dirs) is True assert any(rel(item.relative_path).startswith("src") for item in dirs.items) mixed = finder.mixed_search("src", page_size=10) assert mixed.total_matched >= 3 + assert len(mixed) == len(mixed.items) + assert bool(mixed) is True assert any(isinstance(item, MixedDirItem) for item in mixed.items) assert any(isinstance(item, MixedFileItem) for item in mixed.items) @@ -187,6 +204,8 @@ def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None: plain = finder.grep("needle", before_context=1, after_context=1) assert plain.total_matched == 1 + assert len(plain) == len(plain.items) == 1 + assert bool(plain) is True match = plain.items[0] assert rel(match.relative_path) == "docs/guide.txt" assert match.line_content == "needle target" @@ -240,13 +259,15 @@ def test_grep_cursor_paginates_by_file(sample_dir: str) -> None: exhausted = finder.grep("nonexistent_xyz") assert exhausted.has_more is False assert exhausted.next_cursor() is None + assert len(exhausted) == 0 + assert bool(exhausted) is False def test_multi_grep_and_error_handling(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: assert finder.wait_for_scan(timeout_ms=5000) - result = finder.multi_grep(["def main", "def helper"]) + result = finder.multi_grep(("def main", "def helper")) assert result.total_matched == 2 assert {Path(rel(m.relative_path)).name for m in result.items} == { "main.py", @@ -259,11 +280,11 @@ def test_multi_grep_and_error_handling(sample_dir: str) -> None: def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None: history_db = tmp_path / "history" - selected_file = str(Path(sample_dir) / "src" / "main.py") + selected_file = Path(sample_dir) / "src" / "main.py" with FileFinder( sample_dir, - history_db_path=str(history_db), + history_db_path=history_db, watch=False, enable_content_indexing=False, ) as finder: @@ -273,7 +294,7 @@ def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None: with FileFinder( sample_dir, - history_db_path=str(history_db), + history_db_path=history_db, watch=False, enable_content_indexing=False, ) as finder: @@ -290,14 +311,14 @@ def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None: history_db = tmp_path / "history" with FileFinder( sample_dir, - frecency_db_path=str(frecency_db), - history_db_path=str(history_db), + frecency_db_path=frecency_db, + history_db_path=history_db, watch=False, enable_content_indexing=False, ) as finder: assert finder.wait_for_scan(timeout_ms=5000) - health = finder.health_check(sample_dir) + health = finder.health_check(Path(sample_dir)) assert health["file_picker"]["initialized"] is True assert health["frecency"]["initialized"] is True assert health["query_tracker"]["initialized"] is True From 8c25486c0483291794e631e53d25fc2076814b9c Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 16 Jun 2026 14:19:08 -0700 Subject: [PATCH 08/15] feat(python): async wait_for_scan with blocking variant --- README.md | 2 +- crates/fff-python/src/finder.rs | 5 +- packages/fff-python/README.md | 21 ++++++- packages/fff-python/examples/basic.py | 2 +- packages/fff-python/pyproject.toml | 1 + packages/fff-python/src/fff/__init__.py | 30 +++++++++- packages/fff-python/src/fff/__init__.pyi | 3 +- packages/fff-python/tests/test_finder.py | 72 ++++++++++++++++++------ 8 files changed, 111 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9103c16b..d71976ef 100644 --- a/README.md +++ b/README.md @@ -689,7 +689,7 @@ uv run maturin develop --release from fff import FileFinder with FileFinder("/path/to/project", watch=False) as finder: - finder.wait_for_scan(timeout_ms=5000) + finder.wait_for_scan_blocking(timeout_ms=5000) result = finder.search("main") for item, score in zip(result.items, result.scores): diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index ae11ba0b..beb5aa23 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -129,7 +129,7 @@ fn clear_shared_state( } } -#[pyclass] +#[pyclass(subclass)] pub struct FileFinder { picker: SharedFilePicker, frecency: SharedFrecency, @@ -708,7 +708,8 @@ impl FileFinder { Ok(guard.as_ref().map(|p| p.is_scan_active()).unwrap_or(false)) } - fn wait_for_scan(&self, py: Python<'_>, timeout_ms: u64) -> PyResult { + #[pyo3(signature = (timeout_ms=5000))] + fn wait_for_scan_blocking(&self, py: Python<'_>, timeout_ms: u64) -> PyResult { let picker = self.picker.clone(); py.allow_threads(move || Ok(picker.wait_for_scan(Duration::from_millis(timeout_ms)))) } diff --git a/packages/fff-python/README.md b/packages/fff-python/README.md index b2bd4666..e64aba2f 100644 --- a/packages/fff-python/README.md +++ b/packages/fff-python/README.md @@ -36,7 +36,7 @@ uv run python examples/basic.py . from fff import FileFinder with FileFinder("/path/to/project", watch=False) as finder: - finder.wait_for_scan(timeout_ms=5000) + finder.wait_for_scan_blocking(timeout_ms=5000) print(f"Indexed under {finder.base_path}") result = finder.search("main") @@ -46,6 +46,25 @@ with FileFinder("/path/to/project", watch=False) as finder: print(f"{item.relative_path}: {score.total}") ``` +### Async usage + +`wait_for_scan` is a coroutine that polls the scan status and yields to the +event loop, so it never blocks other tasks. Use `wait_for_scan_blocking` from +synchronous code. + +```python +import asyncio +from fff import FileFinder + +async def main(): + with FileFinder("/path/to/project", watch=False) as finder: + await finder.wait_for_scan(timeout_ms=5000) + result = finder.search("main") + print(result) + +asyncio.run(main()) +``` + ## Building wheels ```bash diff --git a/packages/fff-python/examples/basic.py b/packages/fff-python/examples/basic.py index a975dbf5..5e296c31 100644 --- a/packages/fff-python/examples/basic.py +++ b/packages/fff-python/examples/basic.py @@ -17,7 +17,7 @@ def main() -> int: print(f"Created in {time.time() - start:.2f}s") print("Waiting for scan...") - finder.wait_for_scan(timeout_ms=30000) + finder.wait_for_scan_blocking(timeout_ms=30000) progress = finder.scan_progress print(f"Indexed {progress.scanned_files_count} files") diff --git a/packages/fff-python/pyproject.toml b/packages/fff-python/pyproject.toml index fc62711f..9f278100 100644 --- a/packages/fff-python/pyproject.toml +++ b/packages/fff-python/pyproject.toml @@ -30,3 +30,4 @@ exclude = ["src/**/__pycache__/*", "src/**/*.pyc"] [tool.pytest.ini_options] testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/packages/fff-python/src/fff/__init__.py b/packages/fff-python/src/fff/__init__.py index 9a77f180..e0dab288 100644 --- a/packages/fff-python/src/fff/__init__.py +++ b/packages/fff-python/src/fff/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations +import asyncio + from fff._fff_python import ( DirItem, DirSearchResult, FFFException, - FileFinder, FileItem, GrepMatch, GrepResult, @@ -19,6 +20,33 @@ Score, SearchResult, ) +from fff._fff_python import FileFinder as _FileFinder + +_SCAN_POLL_INTERVAL = 0.05 + + +class FileFinder(_FileFinder): + """File finder with an async, event-loop-friendly scan wait. + + Inherits every method from the native finder; only adds the async + ``wait_for_scan`` on top. Use ``wait_for_scan_blocking`` when a + synchronous wait is acceptable. + """ + + async def wait_for_scan(self, timeout_ms: int = 5000) -> bool: + """Wait for the initial scan without blocking the event loop. + + Polls ``is_scanning`` and yields to the loop between checks. + Returns ``True`` if the scan completed, ``False`` on timeout. + """ + loop = asyncio.get_event_loop() + deadline = loop.time() + timeout_ms / 1000 + while self.is_scanning(): + if loop.time() >= deadline: + return False + await asyncio.sleep(_SCAN_POLL_INTERVAL) + return True + __version__ = "0.1.0" diff --git a/packages/fff-python/src/fff/__init__.pyi b/packages/fff-python/src/fff/__init__.pyi index cc7a2e45..1b1c6564 100644 --- a/packages/fff-python/src/fff/__init__.pyi +++ b/packages/fff-python/src/fff/__init__.pyi @@ -247,7 +247,8 @@ class FileFinder: ) -> GrepResult: ... def scan_files(self) -> None: ... def is_scanning(self) -> bool: ... - def wait_for_scan(self, timeout_ms: int) -> bool: ... + async def wait_for_scan(self, timeout_ms: int = 5000) -> bool: ... + def wait_for_scan_blocking(self, timeout_ms: int = 5000) -> bool: ... def reindex(self, new_path: _PathInput) -> None: ... def refresh_git_status(self) -> int: ... def track_query(self, query: str, selected_file_path: _PathInput) -> bool: ... diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py index 8ae6bb56..cd105899 100644 --- a/packages/fff-python/tests/test_finder.py +++ b/packages/fff-python/tests/test_finder.py @@ -53,7 +53,7 @@ def test_imports_and_package_version() -> None: def test_pathlib_base_path(sample_dir: str) -> None: with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=False) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) assert finder.closed is False assert finder.base_path is not None assert finder.scan_progress.scanned_files_count >= 1 @@ -61,6 +61,42 @@ def test_pathlib_base_path(sample_dir: str) -> None: assert result.total_matched >= 1 +async def test_wait_for_scan_async(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert await finder.wait_for_scan(timeout_ms=5000) is True + assert finder.is_scanning() is False + result = finder.search("main") + assert result.total_matched >= 1 + + +async def test_wait_for_scan_async_does_not_block_loop(sample_dir: str) -> None: + import asyncio + + ticks = 0 + + async def ticker() -> None: + nonlocal ticks + while True: + ticks += 1 + await asyncio.sleep(0.01) + + with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: + background = asyncio.ensure_future(ticker()) + try: + assert await finder.wait_for_scan(timeout_ms=5000) is True + finally: + background.cancel() + # the loop kept running other tasks while we awaited the scan + assert ticks > 0 + + +async def test_wait_for_scan_blocking_and_async_agree(sample_dir: str) -> None: + with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: + assert finder.wait_for_scan_blocking(timeout_ms=5000) is True + # already finished, so the async wait resolves immediately to True + assert await finder.wait_for_scan(timeout_ms=5000) is True + + def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None: with pytest.raises(TypeError): GrepCursor() @@ -69,7 +105,7 @@ def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None: FileFinder(Path(sample_dir), None) with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=True) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) with pytest.raises(TypeError): finder.search("main", None) @@ -87,7 +123,7 @@ def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None: def test_close_and_context_manager(sample_dir: str) -> None: finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False) - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) assert finder.closed is False assert finder.base_path is not None finder.close() @@ -97,7 +133,7 @@ def test_close_and_context_manager(sample_dir: str) -> None: finder.search("main") with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as ctx_finder: - assert ctx_finder.wait_for_scan(timeout_ms=5000) + assert ctx_finder.wait_for_scan_blocking(timeout_ms=5000) assert ctx_finder.closed is False assert ctx_finder.closed is True @@ -105,7 +141,7 @@ def test_close_and_context_manager(sample_dir: str) -> None: ctx_finder.search("main") fresh = FileFinder(sample_dir, watch=False, enable_content_indexing=False) - assert fresh.wait_for_scan(timeout_ms=5000) + assert fresh.wait_for_scan_blocking(timeout_ms=5000) fresh.close() assert fresh.closed is True with pytest.raises(FFFException, match="File picker not initialized"): @@ -114,7 +150,7 @@ def test_close_and_context_manager(sample_dir: str) -> None: def test_reprs(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) assert repr(finder).startswith("FileFinder(") result = finder.search("main") assert repr(result).startswith("SearchResult(") @@ -142,7 +178,7 @@ def test_reprs(sample_dir: str) -> None: def test_file_search_scores_and_pagination(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) result = finder.search("main", page_size=1) assert result.total_matched >= 1 assert len(result) == len(result.items) == 1 @@ -164,7 +200,7 @@ def test_file_search_scores_and_pagination(sample_dir: str) -> None: def test_glob_variants(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) py_files = finder.glob("*.py") assert {Path(rel(item.relative_path)).name for item in py_files.items} == { @@ -182,7 +218,7 @@ def test_glob_variants(sample_dir: str) -> None: def test_directory_and_mixed_search(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) dirs = finder.directory_search("src") assert dirs.total_matched >= 1 @@ -200,7 +236,7 @@ def test_directory_and_mixed_search(sample_dir: str) -> None: def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) plain = finder.grep("needle", before_context=1, after_context=1) assert plain.total_matched == 1 @@ -231,7 +267,7 @@ def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None: def test_grep_invalid_mode_raises(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) with pytest.raises(FFFException, match="invalid grep mode"): finder.grep("needle", mode="typo") with pytest.raises(FFFException, match="invalid grep mode"): @@ -240,7 +276,7 @@ def test_grep_invalid_mode_raises(sample_dir: str) -> None: def test_grep_cursor_paginates_by_file(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) first = finder.grep("def", page_limit=1) assert first.total_matched >= 1 @@ -265,7 +301,7 @@ def test_grep_cursor_paginates_by_file(sample_dir: str) -> None: def test_multi_grep_and_error_handling(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) result = finder.multi_grep(("def main", "def helper")) assert result.total_matched == 2 @@ -288,7 +324,7 @@ def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None: watch=False, enable_content_indexing=False, ) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) assert finder.track_query("main", selected_file) assert finder.get_historical_query(0) == "main" @@ -298,7 +334,7 @@ def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None: watch=False, enable_content_indexing=False, ) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) assert finder.get_historical_query(0) == "main" @@ -316,7 +352,7 @@ def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None: watch=False, enable_content_indexing=False, ) as finder: - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) health = finder.health_check(Path(sample_dir)) assert health["file_picker"]["initialized"] is True @@ -324,12 +360,12 @@ def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None: assert health["query_tracker"]["initialized"] is True finder.reindex(str(other)) - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) result = finder.search("other") assert result.total_matched == 1 assert rel(result.items[0].relative_path) == "other.py" finder.reindex(Path(other)) - assert finder.wait_for_scan(timeout_ms=5000) + assert finder.wait_for_scan_blocking(timeout_ms=5000) result2 = finder.search("other") assert result2.total_matched == 1 From 3c75374fe413a4440dc48c6ad9e7b3e88136be7a Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 16 Jun 2026 15:27:40 -0700 Subject: [PATCH 09/15] fix(python): consistent frecency type and combo defaults matching node --- crates/fff-python/src/conversions.rs | 2 +- crates/fff-python/src/finder.rs | 20 ++++++++++++++++---- crates/fff-python/src/types.rs | 2 +- packages/fff-python/tests/test_finder.py | 12 ++++++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/fff-python/src/conversions.rs b/crates/fff-python/src/conversions.rs index ca21611b..aea5ac17 100644 --- a/crates/fff-python/src/conversions.rs +++ b/crates/fff-python/src/conversions.rs @@ -72,7 +72,7 @@ impl MixedDirItem { Self { relative_path: item.relative_path(picker), dir_name: item.dir_name(picker), - max_access_frecency: item.max_access_frecency() as i64, + max_access_frecency: item.max_access_frecency(), } } } diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index beb5aa23..49da51eb 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -19,6 +19,10 @@ use crate::types::{ use crate::{parse_grep_mode, py_err}; const DEFAULT_SEARCH_PAGE_SIZE: usize = 100; +// Sentinel-to-default conversion for combo boosting, mirroring the C/Node +// bindings: `0` means "use the engine default", not "disable". +const DEFAULT_COMBO_BOOST_MULTIPLIER: i32 = 100; +const DEFAULT_MIN_COMBO_COUNT: u32 = 3; fn defaulted_usize(value: u32, default: usize) -> usize { if value == 0 { default } else { value as usize } @@ -28,6 +32,14 @@ fn defaulted_u64(value: u64, default: u64) -> u64 { if value == 0 { default } else { value } } +fn defaulted_i32(value: i32, default: i32) -> i32 { + if value == 0 { default } else { value } +} + +fn defaulted_u32(value: u32, default: u32) -> u32 { + if value == 0 { default } else { value } +} + fn create_parent_dir(path: &Path) -> PyResult<()> { if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent).map_err(py_err)?; @@ -345,8 +357,8 @@ impl FileFinder { picker.base_path(), page_index, page_size, - combo_boost_score_multiplier, - min_combo_count, + defaulted_i32(combo_boost_score_multiplier, DEFAULT_COMBO_BOOST_MULTIPLIER), + defaulted_u32(min_combo_count, DEFAULT_MIN_COMBO_COUNT), ), ); @@ -512,8 +524,8 @@ impl FileFinder { picker.base_path(), page_index, page_size, - combo_boost_score_multiplier, - min_combo_count, + defaulted_i32(combo_boost_score_multiplier, DEFAULT_COMBO_BOOST_MULTIPLIER), + defaulted_u32(min_combo_count, DEFAULT_MIN_COMBO_COUNT), ), ); diff --git a/crates/fff-python/src/types.rs b/crates/fff-python/src/types.rs index 187e0b49..32c2f134 100644 --- a/crates/fff-python/src/types.rs +++ b/crates/fff-python/src/types.rs @@ -132,7 +132,7 @@ pub struct MixedDirItem { #[pyo3(get)] pub dir_name: String, #[pyo3(get)] - pub max_access_frecency: i64, + pub max_access_frecency: i32, } #[pymethods] diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py index cd105899..b03ba7bc 100644 --- a/packages/fff-python/tests/test_finder.py +++ b/packages/fff-python/tests/test_finder.py @@ -233,6 +233,18 @@ def test_directory_and_mixed_search(sample_dir: str) -> None: assert any(isinstance(item, MixedDirItem) for item in mixed.items) assert any(isinstance(item, MixedFileItem) for item in mixed.items) + # max_access_frecency is the same field on both dir item types, so the + # values must agree for a shared directory regardless of which search + # produced them. + dir_frecency = { + rel(item.relative_path): item.max_access_frecency for item in dirs.items + } + for item in mixed.items: + if isinstance(item, MixedDirItem): + path = rel(item.relative_path) + if path in dir_frecency: + assert item.max_access_frecency == dir_frecency[path] + def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None: with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder: From c1ec7ac6b95adcaea4f83cc95241904692ef7a3e Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 16 Jun 2026 15:33:57 -0700 Subject: [PATCH 10/15] fix(python): health_check defaults to indexed path and reports cwd errors; expand readme --- README.md | 29 +++++++++++++++++++++ crates/fff-python/src/finder.rs | 32 +++++++++++++++++++++--- packages/fff-python/tests/test_finder.py | 8 ++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d71976ef..18140609 100644 --- a/README.md +++ b/README.md @@ -698,6 +698,35 @@ with FileFinder("/path/to/project", watch=False) as finder: hits = finder.grep("class Profile", mode="plain", before_context=1, after_context=1) ``` +### Async usage + +`wait_for_scan` is a coroutine that polls scan status and yields to the event +loop, so it never blocks other tasks. Use `wait_for_scan_blocking` from +synchronous code. + +```python +import asyncio +from fff import FileFinder + +async def main(): + with FileFinder("/path/to/project", watch=False) as finder: + await finder.wait_for_scan(timeout_ms=5000) + result = finder.search("main") + print(result) + +asyncio.run(main()) +``` + +### What you get + +- `search`, `glob`, `directory_search`, `mixed_search` — frecency-ranked fuzzy file/dir search +- `grep` / `multi_grep` — plain, regex, or fuzzy content search with context lines and cursor pagination +- `track_query` / `get_historical_query` — optional frecency and query-history databases +- `reindex`, `refresh_git_status`, `scan_progress`, `health_check` — lifecycle and diagnostics + +Typed result objects (`FileItem`, `Score`, `GrepMatch`, …) with `py.typed` +stubs included. Ships as an `abi3` wheel compatible with Python 3.10+. + Source: [`packages/fff-python/`](./packages/fff-python/). diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index 49da51eb..0f991303 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -853,7 +853,6 @@ impl FileFinder { #[pyo3(signature = (test_path=None))] fn health_check(&self, py: Python<'_>, test_path: Option) -> PyResult> { - let test_path = test_path.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); let picker = self.picker.clone(); let frecency = self.frecency.clone(); let query_tracker = self.query_tracker.clone(); @@ -870,18 +869,43 @@ impl FileFinder { frecency_initialized, query_tracker_initialized, ) = py.allow_threads(move || -> PyResult<_> { + // Resolve the path to inspect: explicit arg → indexed base path → + // process cwd. Report a cwd-resolution failure instead of silently + // discovering from an empty path. + let (test_path, cwd_error) = match test_path { + Some(p) => (Some(p), None), + None => { + let base = picker + .read() + .ok() + .and_then(|g| g.as_ref().map(|p| p.base_path().to_path_buf())); + match base { + Some(p) => (Some(p), None), + None => match std::env::current_dir() { + Ok(p) => (Some(p), None), + Err(e) => ( + None, + Some(format!("could not determine current directory: {}", e)), + ), + }, + } + } + }; + let git_version = git2::Version::get(); let (major, minor, rev) = git_version.libgit2_version(); let git_version = format!("{}.{}.{}", major, minor, rev); - let (repository_found, workdir, git_error) = - match git2::Repository::discover(&test_path) { + let (repository_found, workdir, git_error) = match test_path { + None => (false, None, cwd_error), + Some(test_path) => match git2::Repository::discover(&test_path) { Ok(repo) => ( true, repo.workdir().map(|p| p.to_string_lossy().to_string()), None, ), Err(e) => (false, None, Some(e.message().to_string())), - }; + }, + }; let (picker_initialized, picker_base_path, picker_is_scanning, picker_indexed_files) = { let guard = picker.read().map_err(py_err)?; diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py index b03ba7bc..d1b388f9 100644 --- a/packages/fff-python/tests/test_finder.py +++ b/packages/fff-python/tests/test_finder.py @@ -371,6 +371,14 @@ def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None: assert health["frecency"]["initialized"] is True assert health["query_tracker"]["initialized"] is True + # With no explicit path, the check inspects the indexed base path + # rather than the process cwd. + default_health = finder.health_check() + assert default_health["file_picker"]["base_path"] is not None + assert "error" not in default_health.get("git", {}) or default_health["git"][ + "error" + ] != "could not determine current directory" + finder.reindex(str(other)) assert finder.wait_for_scan_blocking(timeout_ms=5000) result = finder.search("other") From e538a4a419f880bf60ec62fe64880ff9a4828b61 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 16 Jun 2026 16:12:39 -0700 Subject: [PATCH 11/15] chore(python): rename pypi distribution to fff-search --- .github/workflows/release.yaml | 4 ++-- README.md | 2 +- packages/fff-python/README.md | 4 ++-- packages/fff-python/pyproject.toml | 2 +- packages/fff-python/tests/test_finder.py | 2 +- packages/fff-python/uv.lock | 2 +- scripts/release.sh | 6 +++--- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e229e0b1..2a333c90 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -585,7 +585,7 @@ jobs: ## Python Package - `python/*.whl` / `python/*.tar.gz` - Python wheels and sdist - - Install from PyPI: `pip install fff-python` (when published) + - Install from PyPI: `pip install fff-search` (when published) Update mcp via: ```sh @@ -622,7 +622,7 @@ jobs: (github.event_name == 'workflow_dispatch' && inputs.publish_pypi == true) environment: name: pypi - url: https://pypi.org/p/fff-python + url: https://pypi.org/p/fff-search permissions: contents: read id-token: write diff --git a/README.md b/README.md index 18140609..98619cd7 100644 --- a/README.md +++ b/README.md @@ -672,7 +672,7 @@ Stable C ABI. Bind from C/C++, Zig, Go via cgo, Python via ctypes, or anything w ### Install ```bash -pip install fff-python +pip install fff-search ``` Or build and install from source: diff --git a/packages/fff-python/README.md b/packages/fff-python/README.md index e64aba2f..f70686d6 100644 --- a/packages/fff-python/README.md +++ b/packages/fff-python/README.md @@ -1,6 +1,6 @@ -# fff-python +# fff-search -Python bindings for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim), built with [PyO3](https://pyo3.rs/) and [Maturin](https://www.maturin.rs/). +Python bindings for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim), built with [PyO3](https://pyo3.rs/) and [Maturin](https://www.maturin.rs/). Install with `pip install fff-search`; import as `fff`. ## Requirements diff --git a/packages/fff-python/pyproject.toml b/packages/fff-python/pyproject.toml index 9f278100..0dbadabd 100644 --- a/packages/fff-python/pyproject.toml +++ b/packages/fff-python/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "fff-python" +name = "fff-search" version = "0.1.0" description = "Python bindings for FFF (Fast File Finder)" readme = "README.md" diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py index d1b388f9..c85b4e3b 100644 --- a/packages/fff-python/tests/test_finder.py +++ b/packages/fff-python/tests/test_finder.py @@ -46,7 +46,7 @@ def sample_dir() -> str: def test_imports_and_package_version() -> None: - assert fff.__version__ == metadata.version("fff-python") + assert fff.__version__ == metadata.version("fff-search") assert GrepCursor(12).offset == 12 assert "GrepCursor" in fff.__all__ diff --git a/packages/fff-python/uv.lock b/packages/fff-python/uv.lock index a6246844..dbfac864 100644 --- a/packages/fff-python/uv.lock +++ b/packages/fff-python/uv.lock @@ -33,7 +33,7 @@ wheels = [ ] [[package]] -name = "fff-python" +name = "fff-search" version = "0.1.0" source = { editable = "." } diff --git a/scripts/release.sh b/scripts/release.sh index 5ca499a4..2936cf64 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -64,14 +64,14 @@ replace_once( lock_path = root / "packages/fff-python/uv.lock" if lock_path.exists(): text = lock_path.read_text(encoding="utf-8") - marker = '[[package]]\nname = "fff-python"\nversion = "' + marker = '[[package]]\nname = "fff-search"\nversion = "' start = text.find(marker) if start == -1: - raise SystemExit(f"failed to find fff-python package in {lock_path}") + raise SystemExit(f"failed to find fff-search package in {lock_path}") version_start = start + len(marker) version_end = text.find('"', version_start) if version_end == -1: - raise SystemExit(f"failed to find fff-python version end in {lock_path}") + raise SystemExit(f"failed to find fff-search version end in {lock_path}") text = text[:version_start] + version + text[version_end:] lock_path.write_text(text, encoding="utf-8") PY From 366fa8ff157c94c3b9ea4ebbfe741b17ef0d9a2e Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 16 Jun 2026 16:37:59 -0700 Subject: [PATCH 12/15] chore(python): align version to 0.9.4 for unified release --- Cargo.lock | 2 +- crates/fff-python/Cargo.toml | 2 +- packages/fff-python/pyproject.toml | 2 +- packages/fff-python/src/fff/__init__.py | 2 +- packages/fff-python/uv.lock | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e547ad6f..f92ba3f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -701,7 +701,7 @@ dependencies = [ [[package]] name = "fff-python" -version = "0.1.0" +version = "0.9.4" dependencies = [ "fff-query-parser", "fff-search", diff --git a/crates/fff-python/Cargo.toml b/crates/fff-python/Cargo.toml index 9fcedd33..97586356 100644 --- a/crates/fff-python/Cargo.toml +++ b/crates/fff-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fff-python" -version = "0.1.0" +version = "0.9.4" edition = "2024" [lib] diff --git a/packages/fff-python/pyproject.toml b/packages/fff-python/pyproject.toml index 0dbadabd..9080525c 100644 --- a/packages/fff-python/pyproject.toml +++ b/packages/fff-python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fff-search" -version = "0.1.0" +version = "0.9.4" description = "Python bindings for FFF (Fast File Finder)" readme = "README.md" license = { text = "MIT" } diff --git a/packages/fff-python/src/fff/__init__.py b/packages/fff-python/src/fff/__init__.py index e0dab288..4a42000b 100644 --- a/packages/fff-python/src/fff/__init__.py +++ b/packages/fff-python/src/fff/__init__.py @@ -48,7 +48,7 @@ async def wait_for_scan(self, timeout_ms: int = 5000) -> bool: return True -__version__ = "0.1.0" +__version__ = "0.9.4" __all__ = [ "FFFException", diff --git a/packages/fff-python/uv.lock b/packages/fff-python/uv.lock index dbfac864..a01dcde2 100644 --- a/packages/fff-python/uv.lock +++ b/packages/fff-python/uv.lock @@ -34,7 +34,7 @@ wheels = [ [[package]] name = "fff-search" -version = "0.1.0" +version = "0.9.4" source = { editable = "." } [package.optional-dependencies] From f2c9559d1bd851d57883cea923102f650fa7de49 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 16 Jun 2026 16:52:43 -0700 Subject: [PATCH 13/15] chore(release): bump python version with sed instead of inline python --- scripts/release.sh | 77 ++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 44 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 2936cf64..e3acd4fc 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -31,50 +31,39 @@ echo "→ Updating Cargo.toml versions to $VERSION" cargo set-version "$VERSION" echo "→ Updating Python package version to $VERSION" -python - "$VERSION" <<'PY' -from __future__ import annotations - -import re -import sys -from pathlib import Path - -version = sys.argv[1] -root = Path.cwd() - - -def replace_once(path: Path, pattern: str, replacement: str) -> None: - text = path.read_text(encoding="utf-8") - text, count = re.subn(pattern, replacement, text, count=1) - if count != 1: - raise SystemExit(f"failed to update version in {path}") - path.write_text(text, encoding="utf-8") - - -replace_once( - root / "packages/fff-python/pyproject.toml", - r'(?m)^version = "[^"]+"$', - f'version = "{version}"', -) -replace_once( - root / "packages/fff-python/src/fff/__init__.py", - r'(?m)^__version__ = "[^"]+"$', - f'__version__ = "{version}"', -) - -lock_path = root / "packages/fff-python/uv.lock" -if lock_path.exists(): - text = lock_path.read_text(encoding="utf-8") - marker = '[[package]]\nname = "fff-search"\nversion = "' - start = text.find(marker) - if start == -1: - raise SystemExit(f"failed to find fff-search package in {lock_path}") - version_start = start + len(marker) - version_end = text.find('"', version_start) - if version_end == -1: - raise SystemExit(f"failed to find fff-search version end in {lock_path}") - text = text[:version_start] + version + text[version_end:] - lock_path.write_text(text, encoding="utf-8") -PY +PYPROJECT="packages/fff-python/pyproject.toml" +PYINIT="packages/fff-python/src/fff/__init__.py" +UVLOCK="packages/fff-python/uv.lock" + +sed_inplace() { + local file="$1"; shift + local tmp + tmp="$(mktemp)" + sed "$@" "$file" >"$tmp" + mv "$tmp" "$file" +} + +sed_inplace "$PYPROJECT" -e 's/^version = ".*"$/version = "'"$VERSION"'"/' +sed_inplace "$PYINIT" -e 's/^__version__ = ".*"$/__version__ = "'"$VERSION"'"/' + +# uv.lock has a `version` line per package; only touch the one directly +# after the `fff-search` package entry. Multi `-e` keeps the `{ }` block +# portable across BSD and GNU sed. +if [ -f "$UVLOCK" ]; then + sed_inplace "$UVLOCK" \ + -e '/^name = "fff-search"$/{' \ + -e 'n' \ + -e 's/^version = ".*"$/version = "'"$VERSION"'"/' \ + -e '}' +fi + +# Fail loudly if any substitution did not land (mirrors the old guard). +grep -q "^version = \"$VERSION\"$" "$PYPROJECT" || { echo "Error: failed to update version in $PYPROJECT"; exit 1; } +grep -q "^__version__ = \"$VERSION\"$" "$PYINIT" || { echo "Error: failed to update version in $PYINIT"; exit 1; } +if [ -f "$UVLOCK" ]; then + grep -A1 '^name = "fff-search"$' "$UVLOCK" | grep -q "^version = \"$VERSION\"$" \ + || { echo "Error: failed to update fff-search version in $UVLOCK"; exit 1; } +fi git add -A git commit -m "chore: release $VERSION" From 67f27ace3c413f49527aecfc24f7855ee14c8eb0 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 16 Jun 2026 17:04:49 -0700 Subject: [PATCH 14/15] refactor(python): use From trait for core type conversions --- crates/fff-python/src/conversions.rs | 20 ++++++++++---------- crates/fff-python/src/finder.rs | 12 ++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/fff-python/src/conversions.rs b/crates/fff-python/src/conversions.rs index aea5ac17..1656632d 100644 --- a/crates/fff-python/src/conversions.rs +++ b/crates/fff-python/src/conversions.rs @@ -25,8 +25,8 @@ impl From<&fff::Score> for Score { } } -impl FileItem { - pub fn from_core(item: &fff::FileItem, picker: &FilePicker) -> Self { +impl From<(&fff::FileItem, &FilePicker)> for FileItem { + fn from((item, picker): (&fff::FileItem, &FilePicker)) -> Self { Self { relative_path: item.relative_path(picker), file_name: item.file_name(picker), @@ -41,8 +41,8 @@ impl FileItem { } } -impl DirItem { - pub fn from_core(item: &fff::DirItem, picker: &FilePicker) -> Self { +impl From<(&fff::DirItem, &FilePicker)> for DirItem { + fn from((item, picker): (&fff::DirItem, &FilePicker)) -> Self { Self { relative_path: item.relative_path(picker), dir_name: item.dir_name(picker), @@ -51,8 +51,8 @@ impl DirItem { } } -impl MixedFileItem { - pub fn from_core(item: &fff::FileItem, picker: &FilePicker) -> Self { +impl From<(&fff::FileItem, &FilePicker)> for MixedFileItem { + fn from((item, picker): (&fff::FileItem, &FilePicker)) -> Self { Self { relative_path: item.relative_path(picker), file_name: item.file_name(picker), @@ -67,8 +67,8 @@ impl MixedFileItem { } } -impl MixedDirItem { - pub fn from_core(item: &fff::DirItem, picker: &FilePicker) -> Self { +impl From<(&fff::DirItem, &FilePicker)> for MixedDirItem { + fn from((item, picker): (&fff::DirItem, &FilePicker)) -> Self { Self { relative_path: item.relative_path(picker), dir_name: item.dir_name(picker), @@ -77,8 +77,8 @@ impl MixedDirItem { } } -impl GrepMatch { - pub fn from_core(m: &fff::GrepMatch, file: &fff::FileItem, picker: &FilePicker) -> Self { +impl From<(&fff::GrepMatch, &fff::FileItem, &FilePicker)> for GrepMatch { + fn from((m, file, picker): (&fff::GrepMatch, &fff::FileItem, &FilePicker)) -> Self { Self { relative_path: file.relative_path(picker), file_name: file.file_name(picker), diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs index 0f991303..e61232fc 100644 --- a/crates/fff-python/src/finder.rs +++ b/crates/fff-python/src/finder.rs @@ -111,7 +111,7 @@ fn convert_grep_result(result: fff::grep::GrepResult<'_>, picker: &FilePicker) - let items = result .matches .iter() - .map(|m| GrepMatch::from_core(m, result.files[m.file_index], picker)) + .map(|m| GrepMatch::from((m, result.files[m.file_index], picker))) .collect(); GrepResult { @@ -366,7 +366,7 @@ impl FileFinder { items: result .items .iter() - .map(|i| FileItem::from_core(i, picker)) + .map(|i| FileItem::from((*i, picker))) .collect(), scores: convert_scores(&result.scores), total_matched: result.total_matched as u32, @@ -418,7 +418,7 @@ impl FileFinder { items: result .items .iter() - .map(|i| FileItem::from_core(i, picker)) + .map(|i| FileItem::from((*i, picker))) .collect(), scores: convert_scores(&result.scores), total_matched: result.total_matched as u32, @@ -471,7 +471,7 @@ impl FileFinder { items: result .items .iter() - .map(|i| DirItem::from_core(i, picker)) + .map(|i| DirItem::from((*i, picker))) .collect(), scores: convert_scores(&result.scores), total_matched: result.total_matched as u32, @@ -534,10 +534,10 @@ impl FileFinder { .iter() .map(|item| match item { fff::MixedItemRef::File(file) => { - MixedItem::File(MixedFileItem::from_core(file, picker)) + MixedItem::File(MixedFileItem::from((*file, picker))) } fff::MixedItemRef::Dir(dir) => { - MixedItem::Dir(MixedDirItem::from_core(dir, picker)) + MixedItem::Dir(MixedDirItem::from((*dir, picker))) } }) .collect(); From 5c4f84e19825359878ae237631c60380f31be296 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 16 Jun 2026 17:15:28 -0700 Subject: [PATCH 15/15] chore: do not run full prebuild of python wheels on PR --- .github/workflows/release.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2a333c90..47f69d00 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -368,6 +368,9 @@ jobs: build-python: name: Build Python wheels ${{ matrix.target }} (${{ matrix.os }}) + # Wheels are release artifacts; PR validation uses the develop build in + # python.yml, so skip the cross-compile matrix on pull requests. + if: github.event_name != 'pull_request' runs-on: ${{ matrix.os }} permissions: contents: read @@ -433,6 +436,7 @@ jobs: build-python-sdist: name: Build Python sdist + if: github.event_name != 'pull_request' runs-on: ubuntu-latest permissions: contents: read