dmtrkovalenko--fff.nvim
1b279d4d10
Lua CI / lua-language-server type check (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Python CI / Python bindings (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 3s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 1s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 3s
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Failing after 2s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 1s
Rust CI / cargo fmt (push) Failing after 1s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 4s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 1s
Rust CI / cargo clippy (push) Failing after 1s
Spelling / Spell Check with Typos (push) Failing after 1s
Stylua / Check lua files using Stylua (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 4s
e2e Tests / e2e (alpine-musl) (push) Successful in 58m54s
Build & Publish / Build C FFI aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build C FFI x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Has been cancelled
e2e Tests / e2e (macos-latest) (push) Has been cancelled
e2e Tests / e2e (windows-latest) (push) Has been cancelled
Nix CI / check (push) Has been cancelled
Python CI / Python bindings (macos-latest) (push) Has been cancelled
Python CI / Python bindings (windows-latest) (push) Has been cancelled
Build & Publish / Build Neovim aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Neovim x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP aarch64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build MCP x86_64-apple-darwin (push) Has been cancelled
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Has been cancelled
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Has been cancelled
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Has been cancelled
Build & Publish / Release (push) Has been cancelled
Build & Publish / Publish Python wheels to PyPI (push) Has been cancelled
Build & Publish / Publish Rust crates (push) Has been cancelled
Build & Publish / Publish npm packages (push) Has been cancelled
Rust CI / Test (macos-latest) (push) Has been cancelled
Rust CI / Test (windows-latest) (push) Has been cancelled
Rust CI / Fuzz Tests (macos-latest) (push) Has been cancelled
Rust CI / Fuzz Tests (windows-latest) (push) Has been cancelled
983 行
32 KiB
Rust
983 行
32 KiB
Rust
use std::path::{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};
|
|
|
|
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 }
|
|
}
|
|
|
|
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)?;
|
|
}
|
|
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<Score> {
|
|
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((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(subclass)]
|
|
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) {
|
|
clear_shared_state(&self.picker, &self.frecency, &self.query_tracker);
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
follow_symlinks=false,
|
|
))]
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn new(
|
|
py: Python<'_>,
|
|
base_path: PathBuf,
|
|
frecency_db_path: Option<PathBuf>,
|
|
history_db_path: Option<PathBuf>,
|
|
enable_mmap_cache: bool,
|
|
enable_content_indexing: bool,
|
|
watch: bool,
|
|
ai_mode: bool,
|
|
log_file_path: Option<PathBuf>,
|
|
log_level: Option<String>,
|
|
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,
|
|
follow_symlinks: bool,
|
|
) -> PyResult<Self> {
|
|
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();
|
|
|
|
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)?;
|
|
}
|
|
|
|
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)?;
|
|
}
|
|
|
|
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
|
|
};
|
|
|
|
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,
|
|
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_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.close();
|
|
}
|
|
|
|
fn close(&mut self) -> PyResult<()> {
|
|
clear_shared_state(&self.picker, &self.frecency, &self.query_tracker);
|
|
Ok(())
|
|
}
|
|
|
|
#[getter]
|
|
fn closed(&self) -> PyResult<bool> {
|
|
Ok(self.picker.read().map_err(py_err)?.is_none())
|
|
}
|
|
|
|
#[getter]
|
|
fn base_path(&self) -> PyResult<Option<String>> {
|
|
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<ScanProgress> {
|
|
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<String> {
|
|
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)]
|
|
#[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<String>,
|
|
max_threads: u32,
|
|
page_index: u32,
|
|
page_size: u32,
|
|
combo_boost_score_multiplier: i32,
|
|
min_combo_count: u32,
|
|
) -> PyResult<SearchResult> {
|
|
let picker = self.picker.clone();
|
|
let query_tracker = self.query_tracker.clone();
|
|
let query = query.to_string();
|
|
|
|
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,
|
|
defaulted_i32(combo_boost_score_multiplier, DEFAULT_COMBO_BOOST_MULTIPLIER),
|
|
defaulted_u32(min_combo_count, DEFAULT_MIN_COMBO_COUNT),
|
|
),
|
|
);
|
|
|
|
Ok(SearchResult {
|
|
items: result
|
|
.items
|
|
.iter()
|
|
.map(|i| FileItem::from((*i, picker)))
|
|
.collect(),
|
|
scores: convert_scores(&result.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,
|
|
py: Python<'_>,
|
|
pattern: &str,
|
|
current_file: Option<String>,
|
|
max_threads: u32,
|
|
page_index: u32,
|
|
page_size: u32,
|
|
) -> PyResult<SearchResult> {
|
|
let picker = self.picker.clone();
|
|
let pattern = pattern.to_string();
|
|
|
|
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,
|
|
fuzzy_options(
|
|
max_threads,
|
|
current_file.as_deref(),
|
|
picker.base_path(),
|
|
page_index,
|
|
page_size,
|
|
0,
|
|
0,
|
|
),
|
|
);
|
|
|
|
Ok(SearchResult {
|
|
items: result
|
|
.items
|
|
.iter()
|
|
.map(|i| FileItem::from((*i, picker)))
|
|
.collect(),
|
|
scores: convert_scores(&result.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,
|
|
py: Python<'_>,
|
|
query: &str,
|
|
current_file: Option<String>,
|
|
max_threads: u32,
|
|
page_index: u32,
|
|
page_size: u32,
|
|
) -> PyResult<DirSearchResult> {
|
|
let picker = self.picker.clone();
|
|
let query = query.to_string();
|
|
|
|
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 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,
|
|
),
|
|
);
|
|
|
|
Ok(DirSearchResult {
|
|
items: result
|
|
.items
|
|
.iter()
|
|
.map(|i| DirItem::from((*i, picker)))
|
|
.collect(),
|
|
scores: convert_scores(&result.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_score_multiplier=0,
|
|
min_combo_count=0,
|
|
))]
|
|
fn mixed_search(
|
|
&self,
|
|
py: Python<'_>,
|
|
query: &str,
|
|
current_file: Option<String>,
|
|
max_threads: u32,
|
|
page_index: u32,
|
|
page_size: u32,
|
|
combo_boost_score_multiplier: i32,
|
|
min_combo_count: u32,
|
|
) -> PyResult<MixedSearchResult> {
|
|
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 parsed = QueryParser::new(fff_query_parser::MixedSearchConfig).parse(&query);
|
|
let result = picker.fuzzy_search_mixed(
|
|
&parsed,
|
|
qt_guard.as_ref(),
|
|
fuzzy_options(
|
|
max_threads,
|
|
current_file.as_deref(),
|
|
picker.base_path(),
|
|
page_index,
|
|
page_size,
|
|
defaulted_i32(combo_boost_score_multiplier, DEFAULT_COMBO_BOOST_MULTIPLIER),
|
|
defaulted_u32(min_combo_count, DEFAULT_MIN_COMBO_COUNT),
|
|
),
|
|
);
|
|
|
|
let items: Vec<MixedItem> = result
|
|
.items
|
|
.iter()
|
|
.map(|item| match item {
|
|
fff::MixedItemRef::File(file) => {
|
|
MixedItem::File(MixedFileItem::from((*file, picker)))
|
|
}
|
|
fff::MixedItemRef::Dir(dir) => {
|
|
MixedItem::Dir(MixedDirItem::from((*dir, picker)))
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok((
|
|
items,
|
|
convert_scores(&result.scores),
|
|
result.total_matched as u32,
|
|
result.total_files as u32,
|
|
result.total_dirs as u32,
|
|
))
|
|
})?;
|
|
|
|
let items: PyResult<Vec<PyObject>> = 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<GrepResult> {
|
|
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);
|
|
|
|
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 parsed = if picker.mode().is_ai() {
|
|
QueryParser::new(fff_query_parser::AiGrepConfig).parse(&query)
|
|
} else {
|
|
fff::grep::parse_grep_query(&query)
|
|
};
|
|
let options = grep_options(
|
|
mode,
|
|
cursor_offset,
|
|
max_file_size,
|
|
max_matches_per_file,
|
|
smart_case,
|
|
page_limit,
|
|
time_budget_ms,
|
|
before_context,
|
|
after_context,
|
|
classify_definitions,
|
|
);
|
|
|
|
Ok(convert_grep_result(picker.grep(&parsed, &options), picker))
|
|
})
|
|
}
|
|
|
|
#[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<String>,
|
|
constraints: Option<String>,
|
|
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<GrepResult> {
|
|
let picker = self.picker.clone();
|
|
let mode = parse_grep_mode(mode)?;
|
|
let cursor_offset = cursor.map(|c| c.offset as usize).unwrap_or(0);
|
|
|
|
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 parsed_constraints = constraints.as_ref().map(|c| {
|
|
if picker.mode().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 = grep_options(
|
|
mode,
|
|
cursor_offset,
|
|
max_file_size,
|
|
max_matches_per_file,
|
|
smart_case,
|
|
page_limit,
|
|
time_budget_ms,
|
|
before_context,
|
|
after_context,
|
|
classify_definitions,
|
|
);
|
|
|
|
Ok(convert_grep_result(
|
|
picker.multi_grep(&pattern_refs, constraint_refs, &options),
|
|
picker,
|
|
))
|
|
})
|
|
}
|
|
|
|
fn scan_files(&self) -> PyResult<()> {
|
|
self.picker
|
|
.trigger_full_rescan_async(&self.frecency)
|
|
.map_err(py_err)
|
|
}
|
|
|
|
fn is_scanning(&self) -> PyResult<bool> {
|
|
let guard = self.picker.read().map_err(py_err)?;
|
|
Ok(guard.as_ref().map(|p| p.is_scan_active()).unwrap_or(false))
|
|
}
|
|
|
|
#[pyo3(signature = (timeout_ms=5000))]
|
|
fn wait_for_scan_blocking(&self, py: Python<'_>, timeout_ms: u64) -> PyResult<bool> {
|
|
let picker = self.picker.clone();
|
|
py.allow_threads(move || Ok(picker.wait_for_scan(Duration::from_millis(timeout_ms))))
|
|
}
|
|
|
|
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, follow_symlinks) = {
|
|
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(),
|
|
picker.follows_symlinks(),
|
|
)
|
|
} else {
|
|
(false, true, true, FFFMode::default(), false, 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,
|
|
enable_fs_root_scanning: fs_root,
|
|
enable_home_dir_scanning: home_dir,
|
|
},
|
|
)
|
|
.map_err(py_err)
|
|
})
|
|
}
|
|
|
|
fn refresh_git_status(&self, py: Python<'_>) -> PyResult<i64> {
|
|
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)
|
|
})
|
|
}
|
|
|
|
#[pyo3(signature = (query, selected_file_path))]
|
|
fn track_query(
|
|
&self,
|
|
py: Python<'_>,
|
|
query: &str,
|
|
selected_file_path: PathBuf,
|
|
) -> PyResult<bool> {
|
|
let picker = self.picker.clone();
|
|
let query_tracker = self.query_tracker.clone();
|
|
let query = query.to_string();
|
|
|
|
py.allow_threads(move || -> PyResult<bool> {
|
|
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 {
|
|
Ok(false)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn get_historical_query(&self, py: Python<'_>, offset: u64) -> PyResult<Option<String>> {
|
|
let picker = self.picker.clone();
|
|
let query_tracker = self.query_tracker.clone();
|
|
|
|
py.allow_threads(move || -> PyResult<Option<String>> {
|
|
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),
|
|
};
|
|
|
|
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<PathBuf>) -> PyResult<Py<PyDict>> {
|
|
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<_> {
|
|
// 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 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)?;
|
|
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);
|
|
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);
|
|
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);
|
|
frecency_info.set_item("initialized", frecency_initialized)?;
|
|
dict.set_item("frecency", frecency_info)?;
|
|
|
|
let query_info = PyDict::new(py);
|
|
query_info.set_item("initialized", query_tracker_initialized)?;
|
|
dict.set_item("query_tracker", query_info)?;
|
|
|
|
Ok(dict.unbind())
|
|
}
|
|
}
|