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..47f69d00 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: "Manually build and publish Python wheels to PyPI"
+ required: false
+ default: false
+ type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -359,9 +366,101 @@ jobs:
name: mcp-${{ matrix.target }}
path: fff-mcp-${{ matrix.target }}*
+ 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
+ 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
+ if: github.event_name != 'pull_request'
+ 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 +519,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 +542,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 +567,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 +587,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-search` (when published)
+
Update mcp via:
```sh
curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash
@@ -498,6 +617,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: [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') ||
+ (github.event_name == 'workflow_dispatch' && inputs.publish_pypi == true)
+ environment:
+ name: pypi
+ url: https://pypi.org/p/fff-search
+ 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]
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..f92ba3f0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -699,6 +699,16 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "fff-python"
+version = "0.9.4"
+dependencies = [
+ "fff-query-parser",
+ "fff-search",
+ "git2",
+ "pyo3",
+]
+
[[package]]
name = "fff-query-parser"
version = "0.9.4"
@@ -1191,6 +1201,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 +1454,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 +1806,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 +1872,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 +2396,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 +2647,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/README.md b/README.md
index ba9e4909..98619cd7 100644
--- a/README.md
+++ b/README.md
@@ -664,6 +664,75 @@ 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-search
+```
+
+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_blocking(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)
+```
+
+### 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/).
+
+
+
+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
new file mode 100644
index 00000000..97586356
--- /dev/null
+++ b/crates/fff-python/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "fff-python"
+version = "0.9.4"
+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"] }
diff --git a/crates/fff-python/src/conversions.rs b/crates/fff-python/src/conversions.rs
new file mode 100644
index 00000000..1656632d
--- /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 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),
+ 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 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),
+ max_access_frecency: item.max_access_frecency(),
+ }
+ }
+}
+
+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),
+ 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 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),
+ max_access_frecency: item.max_access_frecency(),
+ }
+ }
+}
+
+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),
+ 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..e61232fc
--- /dev/null
+++ b/crates/fff-python/src/finder.rs
@@ -0,0 +1,979 @@
+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 {
+ 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,
+ ))]
+ #[allow(clippy::too_many_arguments)]
+ fn new(
+ py: Python<'_>,
+ 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();
+ 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: 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_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 {
+ Ok(self.picker.read().map_err(py_err)?.is_none())
+ }
+
+ #[getter]
+ fn base_path(&self) -> PyResult