Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ffc3a972c | |||
| 00019beb0c | |||
| d7bc72786d | |||
| 9a6d8ca81a | |||
| 6455ce7c68 | |||
| 0523fe39ff | |||
| 6b01f95ca6 | |||
| 5ab271ea9d | |||
| 448cf3d025 | |||
| 3cc7da787f | |||
| 8b1f3f4e95 | |||
| ca4c32d364 | |||
| f6af8353c3 | |||
| b384bf7dad |
+1
-7
@@ -1,10 +1,4 @@
|
||||
[target.x86_64-apple-darwin]
|
||||
rustflags = [
|
||||
"-C", "link-arg=-undefined",
|
||||
"-C", "link-arg=dynamic_lookup",
|
||||
]
|
||||
|
||||
[target.aarch64-apple-darwin]
|
||||
[target.'cfg(target_os = "macos")']
|
||||
rustflags = [
|
||||
"-C", "link-arg=-undefined",
|
||||
"-C", "link-arg=dynamic_lookup",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Lua E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
MACOSX_DEPLOYMENT_TARGET: "13"
|
||||
|
||||
jobs:
|
||||
lua-tests:
|
||||
name: Lua E2E (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
- os: macos-latest
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: true
|
||||
cache-on-failure: true
|
||||
cache-key: "v1-lua-e2e"
|
||||
rustflags: ""
|
||||
target: ${{ matrix.target || '' }}
|
||||
|
||||
- name: Build Rust binary (Windows)
|
||||
if: matrix.target
|
||||
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
|
||||
- name: Copy binary to target/release (Windows)
|
||||
if: matrix.target
|
||||
shell: bash
|
||||
run: |
|
||||
cp target/${{ matrix.target }}/release/fff_nvim.dll target/release/fff_nvim.dll
|
||||
|
||||
- name: Build Rust binary
|
||||
if: ${{ !matrix.target }}
|
||||
run: cargo build --release -p fff-nvim
|
||||
|
||||
- name: Install Neovim
|
||||
uses: rhysd/action-setup-vim@v1
|
||||
with:
|
||||
neovim: true
|
||||
version: v0.10.4
|
||||
|
||||
- name: Clone plenary.nvim
|
||||
shell: bash
|
||||
run: git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ../plenary.nvim
|
||||
|
||||
- name: Run Lua tests
|
||||
shell: bash
|
||||
run: |
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
+221
-43
@@ -2,11 +2,103 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/prebuild]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
build-nvim:
|
||||
name: Build Neovim ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
## Linux builds (using cargo-zigbuild)
|
||||
# Glibc 2.17 (RHEL 7, CentOS 7 compatible)
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_nvim.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_nvim.so
|
||||
ext: so
|
||||
# Musl (statically linked)
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_nvim.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
|
||||
ext: dylib
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
|
||||
ext: dylib
|
||||
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
|
||||
ext: dll
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
|
||||
ext: dll
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nvim-${{ matrix.target }}
|
||||
path: ${{ matrix.target }}.*
|
||||
|
||||
build-c:
|
||||
name: Build C FFI ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,87 +106,90 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
## Linux builds
|
||||
# Glibc 2.21
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_nvim.so
|
||||
zigbuild_target: x86_64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_c.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_nvim.so
|
||||
# Musl 1.2.3
|
||||
zigbuild_target: aarch64-unknown-linux-gnu.2.17
|
||||
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_c.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_nvim.so
|
||||
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_c.so
|
||||
ext: so
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
|
||||
# # Android (Termux)
|
||||
# - os: ubuntu-latest
|
||||
# target: aarch64-linux-android
|
||||
# artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_c.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
|
||||
artifact_name: target/x86_64-apple-darwin/release/libfff_c.dylib
|
||||
ext: dylib
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
|
||||
artifact_name: target/aarch64-apple-darwin/release/libfff_c.dylib
|
||||
ext: dylib
|
||||
|
||||
## Windows builds
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
|
||||
artifact_name: target/x86_64-pc-windows-msvc/release/fff_c.dll
|
||||
ext: dll
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
|
||||
artifact_name: target/aarch64-pc-windows-msvc/release/fff_c.dll
|
||||
ext: dll
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# - name: Set Rust toolchain
|
||||
# if: contains(matrix.target, 'linux')
|
||||
# # https://github.com/rust-cross/cargo-zigbuild/issues/327
|
||||
# run: echo -e '[toolchain]\nchannel = "nightly-2025-02-19"' > rust-toolchain.toml
|
||||
|
||||
- name: Install Rust
|
||||
run: |
|
||||
# https://github.com/rust-cross/cargo-zigbuild/issues/327
|
||||
rustup toolchain install nightly-2025-02-19
|
||||
rustup default nightly-2025-02-19
|
||||
rustup target add ${{ matrix.target }}
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
cargo install cross --git https://github.com/cross-rs/cross
|
||||
cross build --release --target ${{ matrix.target }}
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.so"
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for macOS
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: |
|
||||
# Ventura (https://en.wikipedia.org/wiki/MacOS_version_history#Releases)
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }}
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.dylib"
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.dll"
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
path: ${{ matrix.target }}*
|
||||
name: c-lib-${{ matrix.target }}
|
||||
path: c-lib-${{ matrix.target }}.*
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: build
|
||||
needs: [build-nvim, build-c]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -106,12 +201,44 @@ jobs:
|
||||
with:
|
||||
path: ./binaries
|
||||
|
||||
- name: Flatten and rename Neovim artifacts
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
# Move nvim artifacts to root level with original naming
|
||||
for dir in nvim-*/; do
|
||||
target="${dir#nvim-}"
|
||||
target="${target%/}"
|
||||
for file in "$dir"*; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
mv "$file" "./$filename"
|
||||
fi
|
||||
done
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Flatten C library artifacts
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
# Move c-lib artifacts to root level
|
||||
for dir in c-lib-*/; do
|
||||
for file in "$dir"*; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
mv "$file" "./$filename"
|
||||
fi
|
||||
done
|
||||
rmdir "$dir" 2>/dev/null || true
|
||||
done
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: ./binaries
|
||||
run: |
|
||||
ls -a
|
||||
for file in ./**/*; do
|
||||
sha256sum "$file" > "${file}.sha256"
|
||||
ls -la
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [[ ! "$file" == *.sha256 ]]; then
|
||||
sha256sum "$file" > "${file}.sha256"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Prepare tag
|
||||
@@ -127,9 +254,60 @@ jobs:
|
||||
name: "${{ steps.vars.outputs.tag }}"
|
||||
tag_name: "${{ steps.vars.outputs.tag }}"
|
||||
token: ${{ github.token }}
|
||||
files: ./binaries/**/*
|
||||
files: ./binaries/*
|
||||
draft: false
|
||||
prerelease: true
|
||||
generate_release_notes: false
|
||||
body: |
|
||||
Nightly release from commit: ${{ github.sha }}
|
||||
Nightly release from commit: ${{ github.sha }}
|
||||
|
||||
## Neovim Plugin
|
||||
- `{target}.so` / `.dylib` / `.dll` - Lua module for Neovim
|
||||
|
||||
## C FFI Library (for Bun/Node/Python)
|
||||
- `c-lib-{target}.so` / `.dylib` / `.dll` - C FFI library
|
||||
|
||||
comment-on-pr:
|
||||
name: Comment on PR
|
||||
needs: [build-nvim, build-c]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find existing comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: "<!-- fff-nvim-build-comment -->"
|
||||
|
||||
- name: Create or update PR comment
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- fff-nvim-build-comment -->
|
||||
## Build Artifacts for your PR
|
||||
|
||||
### Neovim Plugin
|
||||
Test with lazy.nvim:
|
||||
```lua
|
||||
{
|
||||
"dmtrKovalenko/fff.nvim",
|
||||
tag = "${{ steps.vars.outputs.short_sha }}",
|
||||
}
|
||||
```
|
||||
|
||||
### Bun/TypeScript Package
|
||||
The `fff` npm package will download binaries from this release automatically.
|
||||
|
||||
---
|
||||
*Built from ${{ github.sha }}*
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Ensure consistent macOS deployment target across all compiled objects
|
||||
# (Rust, cc-compiled C code, and Zig-compiled zlob) to avoid linker warnings
|
||||
MACOSX_DEPLOYMENT_TARGET: "13"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -15,22 +18,26 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- name: Install Lua
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: brew install lua
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
# Zig is required to compile zlob
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache: true
|
||||
cache-on-failure: true
|
||||
cache-key: "v1-rust"
|
||||
components: rustfmt, clippy
|
||||
target: wasm32-unknown-unknown
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
run: cargo test --verbose --workspace --exclude fff-nvim
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
@@ -50,10 +57,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Zig is required to compile zlob
|
||||
- name: Install Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
Generated
+195
-34
@@ -78,6 +78,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -145,6 +165,15 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.1"
|
||||
@@ -169,7 +198,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -199,6 +228,17 @@ dependencies = [
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.53"
|
||||
@@ -381,6 +421,12 @@ dependencies = [
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -404,7 +450,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fff_nvim"
|
||||
name = "fff-c"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fff-core",
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"mimalloc",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"heed",
|
||||
"ignore",
|
||||
"neo_frizbee",
|
||||
"notify",
|
||||
"notify-debouncer-full 0.7.0",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"parking_lot",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"rayon",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"smartstring",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"zlob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-nvim"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
@@ -413,6 +508,8 @@ dependencies = [
|
||||
"criterion",
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"fff-core",
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"heed",
|
||||
@@ -421,19 +518,30 @@ dependencies = [
|
||||
"mlua",
|
||||
"neo_frizbee",
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"notify-debouncer-full 0.6.0",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"rayon",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"smartstring",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"zlob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fff-query-parser"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"smallvec",
|
||||
"zlob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -511,8 +619,6 @@ dependencies = [
|
||||
"libc",
|
||||
"libgit2-sys",
|
||||
"log",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -522,6 +628,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0"
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.16"
|
||||
@@ -843,12 +955,20 @@ checksum = "1c42fe03df2bd3c53a3a9c7317ad91d80c81cd1fb0caec8d7cc4cd2bfa10c222"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libssh2-sys",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.43"
|
||||
@@ -869,20 +989,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libssh2-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.22"
|
||||
@@ -958,6 +1064,12 @@ dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.0.4"
|
||||
@@ -982,7 +1094,7 @@ dependencies = [
|
||||
"mlua_derive",
|
||||
"num-traits",
|
||||
"parking_lot",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
@@ -1032,9 +1144,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "neo_frizbee"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b4421f748f561dd0bb677e46f2e6c1ed2c52ba7130349229a73445af5010d17"
|
||||
checksum = "8d53e16653662cf456e2b8048bc99db6ca15d971f3541f679269a23ed8a42acf"
|
||||
dependencies = [
|
||||
"multiversion",
|
||||
"rayon",
|
||||
@@ -1052,6 +1164,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "8.2.0"
|
||||
@@ -1083,6 +1205,19 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "2.0.0"
|
||||
@@ -1152,12 +1287,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.1+3.5.1"
|
||||
@@ -1343,6 +1472,16 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
@@ -1481,6 +1620,12 @@ version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -2006,7 +2151,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
@@ -2039,13 +2184,19 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2054,7 +2205,7 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2121,7 +2272,7 @@ version = "0.53.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
"windows_aarch64_gnullvm 0.53.0",
|
||||
"windows_aarch64_msvc 0.53.0",
|
||||
"windows_i686_gnu 0.53.0",
|
||||
@@ -2382,3 +2533,13 @@ dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlob"
|
||||
version = "1.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d3608be1d193aac6f33fd38cd47c2bca4938ab41006195e8dcd291d87a5428f"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.9.1",
|
||||
]
|
||||
|
||||
+26
-43
@@ -1,63 +1,46 @@
|
||||
[package]
|
||||
name = "fff_nvim"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/fff-c",
|
||||
"crates/fff-core",
|
||||
"crates/fff-nvim",
|
||||
"crates/fff-query-parser",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
path = "lua/fff/rust/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "test_watcher"
|
||||
path = "src/bin/test_watcher.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "jemalloc_profile"
|
||||
path = "src/bin/jemalloc_profile.rs"
|
||||
[[bin]]
|
||||
name = "search_profiler"
|
||||
path = "src/bin/search_profiler.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bench_search_only"
|
||||
path = "src/bin/bench_search_only.rs"
|
||||
|
||||
[[bench]]
|
||||
name = "query_tracker_bench"
|
||||
harness = false
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
# Shared dependencies
|
||||
ahash = "0.8"
|
||||
blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
ctrlc = "3.4.2"
|
||||
dirs = "5.0"
|
||||
git2 = "0.20.2"
|
||||
dunce = "1.0"
|
||||
# git2 - base config without TLS (each crate adds platform-specific TLS)
|
||||
git2 = { version = "0.20.2", default-features = false, features = [
|
||||
"vendored-libgit2",
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
mimalloc = "0.1.47"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { version = "0.7.1" }
|
||||
neo_frizbee = { version = "0.7.2" }
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.6"
|
||||
notify-debouncer-full = "0.7"
|
||||
once_cell = "1.20.2"
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
parking_lot = "0.12"
|
||||
pathdiff = "0.2.1"
|
||||
rayon = "1.8.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
smallvec = { version = "1.13", features = ["const_generics", "union"] }
|
||||
thiserror = "2.0.10"
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
tempfile = "3.8"
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[[bench]]
|
||||
name = "indexing_and_search"
|
||||
harness = false
|
||||
[profile.bench]
|
||||
inherits = "release"
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Picked glibc 2.21 to support Ubuntu 14.04+
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
zig = "2.17"
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
zig = "2.17"
|
||||
@@ -0,0 +1,28 @@
|
||||
PLENARY_DIR ?= ../plenary.nvim
|
||||
|
||||
.PHONY: build test test-rust test-lua test-setup
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
|
||||
test-setup:
|
||||
@if [ ! -d "$(PLENARY_DIR)" ]; then \
|
||||
echo "Cloning plenary.nvim..."; \
|
||||
git clone --depth 1 https://github.com/nvim-lua/plenary.nvim $(PLENARY_DIR); \
|
||||
fi
|
||||
|
||||
test-rust:
|
||||
cargo test --verbose --workspace --exclude fff-nvim
|
||||
|
||||
test-lua: test-setup build
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
-c "PlenaryBustedFile tests/fff_core_spec.lua" 2>&1
|
||||
|
||||
test: test-rust test-lua
|
||||
|
||||
format-rust:
|
||||
cargo fmt --all
|
||||
format-lua:
|
||||
stylua .
|
||||
|
||||
format: format-rust format-lua
|
||||
@@ -11,8 +11,7 @@
|
||||
<img alt="Stars" src="https://img.shields.io/github/stars/dmtrKovalenko/fff.nvim?style=for-the-badge&logo=starship&color=C9CBFF&logoColor=D9E0EE&labelColor=302D41"></a>
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/issues" style="text-decoration: none">
|
||||
<img alt="Issues" src="https://img.shields.io/github/issues/dmtrKovalenko/fff.nvim?style=for-the-badge&logo=bilibili&color=F5E0DC&logoColor=D9E0EE&labelColor=302D41"></a>
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/contributors" style="text-decoration: none">
|
||||
<img alt="Contributors" src="https://img.shields.io/github/contributors/dmtrKovalenko/fff.nvim?color=%23DDB6F2&label=CONTRIBUTORS&logo=git&style=for-the-badge&logoColor=D9E0EE&labelColor=302D41"/></a>
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/contributors" style="text-decoration: none"> <img alt="Contributors" src="https://img.shields.io/github/contributors/dmtrKovalenko/fff.nvim?color=%23DDB6F2&label=CONTRIBUTORS&logo=git&style=for-the-badge&logoColor=D9E0EE&labelColor=302D41"/></a>
|
||||
</p>
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
|
||||
@@ -124,6 +123,12 @@ require('fff').setup({
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -133,7 +138,6 @@ require('fff').setup({
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
wrap_lines = false,
|
||||
show_file_info = true,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
@@ -215,6 +219,7 @@ require('fff').setup({
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
@@ -321,6 +326,19 @@ require('fff').setup({
|
||||
})
|
||||
```
|
||||
|
||||
#### File Filtering
|
||||
|
||||
FFF.nvim respects `.gitignore` patterns automatically. To filter files from the picker without modifying `.gitignore`, create a `.ignore` file in your project root:
|
||||
|
||||
```gitignore
|
||||
# Exclude all markdown files
|
||||
*.md
|
||||
|
||||
# Exclude specific subdirectory
|
||||
docs/archive/**/*.md
|
||||
```
|
||||
|
||||
Run `:FFFScan` to force a rescan if needed.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "fff-c"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "C FFI bindings for fff-core - use from any language with C FFI support"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
mimalloc.workspace = true
|
||||
once_cell.workspace = true
|
||||
tracing.workspace = true
|
||||
git2.workspace = true
|
||||
|
||||
fff-core = { path = "../fff-core" }
|
||||
fff-query-parser = { path = "../fff-query-parser" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -0,0 +1,221 @@
|
||||
//! FFI-compatible type definitions
|
||||
//!
|
||||
//! These types use #[repr(C)] for C ABI compatibility and implement
|
||||
//! serde traits for JSON serialization.
|
||||
|
||||
use std::ffi::{CString, c_char};
|
||||
use std::ptr;
|
||||
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FileItem, Location, Score, SearchResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Result type returned by all FFI functions
|
||||
/// Returned as a heap-allocated pointer that must be freed with fff_free_result
|
||||
#[repr(C)]
|
||||
pub struct FffResult {
|
||||
/// Whether the operation succeeded
|
||||
pub success: bool,
|
||||
/// JSON data on success (null-terminated string, caller must free)
|
||||
pub data: *mut c_char,
|
||||
/// Error message on failure (null-terminated string, caller must free)
|
||||
pub error: *mut c_char,
|
||||
}
|
||||
|
||||
impl FffResult {
|
||||
/// Create a successful result with no data, returned as heap pointer
|
||||
pub fn ok_empty() -> *mut Self {
|
||||
Box::into_raw(Box::new(FffResult {
|
||||
success: true,
|
||||
data: ptr::null_mut(),
|
||||
error: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a successful result with data, returned as heap pointer
|
||||
pub fn ok_data(data: &str) -> *mut Self {
|
||||
Box::into_raw(Box::new(FffResult {
|
||||
success: true,
|
||||
data: CString::new(data).unwrap_or_default().into_raw(),
|
||||
error: ptr::null_mut(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create an error result, returned as heap pointer
|
||||
pub fn err(error: &str) -> *mut Self {
|
||||
Box::into_raw(Box::new(FffResult {
|
||||
success: false,
|
||||
data: ptr::null_mut(),
|
||||
error: CString::new(error).unwrap_or_default().into_raw(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialization options (JSON-deserializable)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InitOptions {
|
||||
/// Base directory to index (required)
|
||||
pub base_path: String,
|
||||
/// Path to frecency database (optional, omit to skip frecency initialization)
|
||||
pub frecency_db_path: Option<String>,
|
||||
/// Path to query history database (optional, omit to skip query tracker initialization)
|
||||
pub history_db_path: Option<String>,
|
||||
/// Use unsafe no-lock mode for databases (optional, defaults to false)
|
||||
#[serde(default)]
|
||||
pub use_unsafe_no_lock: bool,
|
||||
}
|
||||
|
||||
/// Search options (JSON-deserializable)
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct SearchOptions {
|
||||
/// Maximum threads for parallel search (0 = auto)
|
||||
pub max_threads: Option<usize>,
|
||||
/// Current file path (for deprioritization)
|
||||
pub current_file: Option<String>,
|
||||
/// Combo boost score multiplier
|
||||
pub combo_boost_multiplier: Option<i32>,
|
||||
/// Minimum combo count for boost
|
||||
pub min_combo_count: Option<u32>,
|
||||
/// Page index for pagination
|
||||
pub page_index: Option<usize>,
|
||||
/// Page size for pagination
|
||||
pub page_size: Option<usize>,
|
||||
}
|
||||
|
||||
/// Scan progress (JSON-serializable)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScanProgress {
|
||||
pub scanned_files_count: usize,
|
||||
pub is_scanning: bool,
|
||||
}
|
||||
|
||||
/// File item for JSON serialization
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileItemJson {
|
||||
pub path: String,
|
||||
pub relative_path: String,
|
||||
pub file_name: String,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
pub access_frecency_score: i64,
|
||||
pub modification_frecency_score: i64,
|
||||
pub total_frecency_score: i64,
|
||||
pub git_status: String,
|
||||
}
|
||||
|
||||
impl FileItemJson {
|
||||
pub fn from_file_item(item: &FileItem) -> Self {
|
||||
FileItemJson {
|
||||
path: item.path.to_string_lossy().to_string(),
|
||||
relative_path: item.relative_path.clone(),
|
||||
file_name: item.file_name.clone(),
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
access_frecency_score: item.access_frecency_score,
|
||||
modification_frecency_score: item.modification_frecency_score,
|
||||
total_frecency_score: item.total_frecency_score,
|
||||
git_status: format_git_status(item.git_status).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Score for JSON serialization
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScoreJson {
|
||||
pub total: i32,
|
||||
pub base_score: i32,
|
||||
pub filename_bonus: i32,
|
||||
pub special_filename_bonus: i32,
|
||||
pub frecency_boost: i32,
|
||||
pub distance_penalty: i32,
|
||||
pub current_file_penalty: i32,
|
||||
pub combo_match_boost: i32,
|
||||
pub exact_match: bool,
|
||||
pub match_type: String,
|
||||
}
|
||||
|
||||
impl ScoreJson {
|
||||
pub fn from_score(score: &Score) -> Self {
|
||||
ScoreJson {
|
||||
total: score.total,
|
||||
base_score: score.base_score,
|
||||
filename_bonus: score.filename_bonus,
|
||||
special_filename_bonus: score.special_filename_bonus,
|
||||
frecency_boost: score.frecency_boost,
|
||||
distance_penalty: score.distance_penalty,
|
||||
current_file_penalty: score.current_file_penalty,
|
||||
combo_match_boost: score.combo_match_boost,
|
||||
exact_match: score.exact_match,
|
||||
match_type: score.match_type.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Location for JSON serialization
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum LocationJson {
|
||||
#[serde(rename = "line")]
|
||||
Line { line: i32 },
|
||||
#[serde(rename = "position")]
|
||||
Position { line: i32, col: i32 },
|
||||
#[serde(rename = "range")]
|
||||
Range {
|
||||
start: PositionJson,
|
||||
end: PositionJson,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PositionJson {
|
||||
pub line: i32,
|
||||
pub col: i32,
|
||||
}
|
||||
|
||||
impl LocationJson {
|
||||
pub fn from_location(loc: &Location) -> Self {
|
||||
match loc {
|
||||
Location::Line(line) => LocationJson::Line { line: *line },
|
||||
Location::Position { line, col } => LocationJson::Position {
|
||||
line: *line,
|
||||
col: *col,
|
||||
},
|
||||
Location::Range { start, end } => LocationJson::Range {
|
||||
start: PositionJson {
|
||||
line: start.0,
|
||||
col: start.1,
|
||||
},
|
||||
end: PositionJson {
|
||||
line: end.0,
|
||||
col: end.1,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search result for JSON serialization
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchResultJson {
|
||||
pub items: Vec<FileItemJson>,
|
||||
pub scores: Vec<ScoreJson>,
|
||||
pub total_matched: usize,
|
||||
pub total_files: usize,
|
||||
pub location: Option<LocationJson>,
|
||||
}
|
||||
|
||||
impl SearchResultJson {
|
||||
pub fn from_search_result(result: &SearchResult) -> Self {
|
||||
SearchResultJson {
|
||||
items: result
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| FileItemJson::from_file_item(item))
|
||||
.collect(),
|
||||
scores: result.scores.iter().map(ScoreJson::from_score).collect(),
|
||||
total_matched: result.total_matched,
|
||||
total_files: result.total_files,
|
||||
location: result.location.as_ref().map(LocationJson::from_location),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
//! C FFI bindings for fff-core
|
||||
//!
|
||||
//! This crate provides C-compatible FFI exports that can be used from any language
|
||||
//! with C FFI support (Bun, Node.js, Python, Ruby, etc.).
|
||||
//!
|
||||
//! All functions return a pointer to a heap-allocated `FffResult` struct containing
|
||||
//! success status and either data (as JSON string) or an error message.
|
||||
//! Memory must be freed using `fff_free_result`.
|
||||
|
||||
use std::ffi::{CStr, CString, c_char};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
mod ffi_types;
|
||||
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use fff_core::{DbHealthChecker, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
|
||||
use ffi_types::{FffResult, InitOptions, ScanProgress, SearchOptions};
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
/// Helper to convert C string to Rust &str.
|
||||
///
|
||||
/// Returns `None` if the pointer is null or the string is not valid UTF-8.
|
||||
/// This is more efficient than `to_string_lossy()` as it returns a borrowed
|
||||
/// `&str` directly without `Cow` overhead, and avoids replacement character
|
||||
/// scanning since callers are expected to provide valid UTF-8.
|
||||
unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
|
||||
if s.is_null() {
|
||||
None
|
||||
} else {
|
||||
unsafe { CStr::from_ptr(s).to_str().ok() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the file finder with the given options (JSON string)
|
||||
///
|
||||
/// # Safety
|
||||
/// `opts_json` must be a valid null-terminated UTF-8 string
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_init(opts_json: *const c_char) -> *mut FffResult {
|
||||
let opts_str = match unsafe { cstr_to_str(opts_json) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Options JSON is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let opts: InitOptions = match serde_json::from_str(opts_str) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return FffResult::err(&format!("Failed to parse options: {}", e)),
|
||||
};
|
||||
|
||||
// Initialize frecency tracker if path is provided
|
||||
if let Some(frecency_path) = opts.frecency_db_path {
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = PathBuf::from(&frecency_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let mut frecency = match FRECENCY.write() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire frecency lock: {}", e)),
|
||||
};
|
||||
*frecency = None;
|
||||
match FrecencyTracker::new(&frecency_path, opts.use_unsafe_no_lock) {
|
||||
Ok(tracker) => *frecency = Some(tracker),
|
||||
Err(e) => return FffResult::err(&format!("Failed to init frecency db: {}", e)),
|
||||
}
|
||||
drop(frecency);
|
||||
}
|
||||
|
||||
// Initialize query tracker if path is provided
|
||||
if let Some(history_path) = opts.history_db_path {
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = PathBuf::from(&history_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let mut query_tracker = match QUERY_TRACKER.write() {
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
return FffResult::err(&format!("Failed to acquire query tracker lock: {}", e));
|
||||
}
|
||||
};
|
||||
*query_tracker = None;
|
||||
match QueryTracker::new(&history_path, opts.use_unsafe_no_lock) {
|
||||
Ok(tracker) => *query_tracker = Some(tracker),
|
||||
Err(e) => return FffResult::err(&format!("Failed to init query tracker db: {}", e)),
|
||||
}
|
||||
drop(query_tracker);
|
||||
}
|
||||
|
||||
// Initialize file picker
|
||||
let mut file_picker = match FILE_PICKER.write() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
if file_picker.is_some() {
|
||||
// Already initialized, clean up first
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
}
|
||||
|
||||
match FilePicker::new(opts.base_path) {
|
||||
Ok(picker) => {
|
||||
*file_picker = Some(picker);
|
||||
FffResult::ok_empty()
|
||||
}
|
||||
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Destroy all resources and clean up
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_destroy() -> *mut FffResult {
|
||||
// Clean up file picker
|
||||
if let Ok(mut file_picker) = FILE_PICKER.write()
|
||||
&& let Some(mut picker) = file_picker.take()
|
||||
{
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
// Clean up frecency
|
||||
if let Ok(mut frecency) = FRECENCY.write() {
|
||||
*frecency = None;
|
||||
}
|
||||
|
||||
// Clean up query tracker
|
||||
if let Ok(mut query_tracker) = QUERY_TRACKER.write() {
|
||||
*query_tracker = None;
|
||||
}
|
||||
|
||||
FffResult::ok_empty()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Perform fuzzy search on indexed files
|
||||
///
|
||||
/// # Safety
|
||||
/// `query` and `opts_json` must be valid null-terminated UTF-8 strings
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_search(
|
||||
query: *const c_char,
|
||||
opts_json: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let query_str = match unsafe { cstr_to_str(query) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Query is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let opts: SearchOptions = if opts_json.is_null() {
|
||||
SearchOptions::default()
|
||||
} else {
|
||||
unsafe { cstr_to_str(opts_json) }
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let file_picker_guard = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker_guard.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized. Call fff_init first."),
|
||||
};
|
||||
|
||||
let base_path = picker.base_path();
|
||||
let min_combo_count = opts.min_combo_count.unwrap_or(3);
|
||||
|
||||
// Get last same query entry for combo matching
|
||||
let last_same_query_entry = {
|
||||
let query_tracker = match QUERY_TRACKER.read() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::err("Failed to acquire query tracker lock"),
|
||||
};
|
||||
|
||||
query_tracker.as_ref().and_then(|tracker| {
|
||||
tracker
|
||||
.get_last_query_entry(query_str, base_path, min_combo_count)
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
};
|
||||
|
||||
// Parse the query
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query_str);
|
||||
|
||||
let results = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
query_str,
|
||||
parsed,
|
||||
FuzzySearchOptions {
|
||||
max_threads: opts.max_threads.unwrap_or(0),
|
||||
current_file: opts.current_file.as_deref(),
|
||||
project_path: Some(picker.base_path()),
|
||||
last_same_query_match: last_same_query_entry.as_ref(),
|
||||
combo_boost_score_multiplier: opts.combo_boost_multiplier.unwrap_or(100),
|
||||
min_combo_count,
|
||||
pagination: PaginationArgs {
|
||||
offset: opts.page_index.unwrap_or(0),
|
||||
limit: opts.page_size.unwrap_or(100),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Convert to JSON
|
||||
let json_result = ffi_types::SearchResultJson::from_search_result(&results);
|
||||
match serde_json::to_string(&json_result) {
|
||||
Ok(json) => FffResult::ok_data(&json),
|
||||
Err(e) => FffResult::err(&format!("Failed to serialize results: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File Index Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Trigger a rescan of the file index
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_scan_files() -> *mut FffResult {
|
||||
let mut file_picker = match FILE_PICKER.write() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker.as_mut() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
match picker.trigger_rescan() {
|
||||
Ok(_) => FffResult::ok_empty(),
|
||||
Err(e) => FffResult::err(&format!("Failed to trigger rescan: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a scan is currently in progress
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_is_scanning() -> bool {
|
||||
FILE_PICKER
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|p| p.is_scan_active()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get scan progress information
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_get_scan_progress() -> *mut FffResult {
|
||||
let file_picker = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
let progress = picker.get_scan_progress();
|
||||
let result = ScanProgress {
|
||||
scanned_files_count: progress.scanned_files_count,
|
||||
is_scanning: progress.is_scanning,
|
||||
};
|
||||
|
||||
match serde_json::to_string(&result) {
|
||||
Ok(json) => FffResult::ok_data(&json),
|
||||
Err(e) => FffResult::err(&format!("Failed to serialize progress: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for initial scan to complete
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_wait_for_scan(timeout_ms: u64) -> *mut FffResult {
|
||||
let file_picker = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker.as_ref() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::err("File picker not initialized"),
|
||||
};
|
||||
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
let start = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while picker.is_scan_active() {
|
||||
if start.elapsed() >= timeout {
|
||||
return FffResult::ok_data("false");
|
||||
}
|
||||
std::thread::sleep(sleep_duration);
|
||||
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
|
||||
}
|
||||
|
||||
FffResult::ok_data("true")
|
||||
}
|
||||
|
||||
/// Restart indexing in a new directory
|
||||
///
|
||||
/// # Safety
|
||||
/// `new_path` must be a valid null-terminated UTF-8 string
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_restart_index(new_path: *const c_char) -> *mut FffResult {
|
||||
let path_str = match unsafe { cstr_to_str(new_path) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Path is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let path = PathBuf::from(&path_str);
|
||||
if !path.exists() {
|
||||
return FffResult::err(&format!("Path does not exist: {}", path_str));
|
||||
}
|
||||
|
||||
let canonical_path = match fff_core::path_utils::canonicalize(&path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return FffResult::err(&format!("Failed to canonicalize path: {}", e)),
|
||||
};
|
||||
|
||||
let mut file_picker = match FILE_PICKER.write() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
// Stop existing picker
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
// Create new picker
|
||||
match FilePicker::new(canonical_path.to_string_lossy().to_string()) {
|
||||
Ok(picker) => {
|
||||
*file_picker = Some(picker);
|
||||
FffResult::ok_empty()
|
||||
}
|
||||
Err(e) => FffResult::err(&format!("Failed to init file picker: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Frecency Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Track file access for frecency scoring
|
||||
///
|
||||
/// # Safety
|
||||
/// `file_path` must be a valid null-terminated UTF-8 string
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_track_access(file_path: *const c_char) -> *mut FffResult {
|
||||
let path_str = match unsafe { cstr_to_str(file_path) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("File path is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let file_path = PathBuf::from(&path_str);
|
||||
|
||||
// Track in frecency DB
|
||||
let frecency_guard = match FRECENCY.read() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire frecency lock: {}", e)),
|
||||
};
|
||||
|
||||
let frecency = match frecency_guard.as_ref() {
|
||||
Some(f) => f,
|
||||
None => return FffResult::ok_data("false"), // Frecency not initialized, skip
|
||||
};
|
||||
|
||||
if let Err(e) = frecency.track_access(&file_path) {
|
||||
return FffResult::err(&format!("Failed to track access: {}", e));
|
||||
}
|
||||
drop(frecency_guard);
|
||||
|
||||
// Update in file picker
|
||||
let mut file_picker = match FILE_PICKER.write() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return FffResult::err(&format!("Failed to acquire file picker lock: {}", e)),
|
||||
};
|
||||
|
||||
let picker = match file_picker.as_mut() {
|
||||
Some(p) => p,
|
||||
None => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
let frecency_guard = match FRECENCY.read() {
|
||||
Ok(f) => f,
|
||||
Err(_) => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
if let Some(ref frecency) = *frecency_guard {
|
||||
let _ = picker.update_single_file_frecency(&file_path, frecency);
|
||||
}
|
||||
|
||||
FffResult::ok_data("true")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Git Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Refresh git status cache
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_refresh_git_status() -> *mut FffResult {
|
||||
match FilePicker::refresh_git_status_global() {
|
||||
Ok(count) => FffResult::ok_data(&count.to_string()),
|
||||
Err(e) => FffResult::err(&format!("Failed to refresh git status: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Tracking Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Track query completion for smart suggestions
|
||||
///
|
||||
/// # Safety
|
||||
/// `query` and `file_path` must be valid null-terminated UTF-8 strings
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_track_query(
|
||||
query: *const c_char,
|
||||
file_path: *const c_char,
|
||||
) -> *mut FffResult {
|
||||
let query_str = match unsafe { cstr_to_str(query) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("Query is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let path_str = match unsafe { cstr_to_str(file_path) } {
|
||||
Some(s) => s,
|
||||
None => return FffResult::err("File path is null or invalid UTF-8"),
|
||||
};
|
||||
|
||||
let file_path = match fff_core::path_utils::canonicalize(path_str) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return FffResult::err(&format!("Failed to canonicalize path: {}", e)),
|
||||
};
|
||||
|
||||
let project_path = {
|
||||
let file_picker = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
Err(_) => return FffResult::ok_data("false"),
|
||||
};
|
||||
match file_picker.as_ref() {
|
||||
Some(p) => p.base_path().to_path_buf(),
|
||||
None => return FffResult::ok_data("false"),
|
||||
}
|
||||
};
|
||||
|
||||
let mut query_tracker = match QUERY_TRACKER.write() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::ok_data("false"),
|
||||
};
|
||||
|
||||
if let Some(ref mut tracker) = *query_tracker
|
||||
&& let Err(e) = tracker.track_query_completion(query_str, &project_path, &file_path)
|
||||
{
|
||||
return FffResult::err(&format!("Failed to track query: {}", e));
|
||||
}
|
||||
|
||||
FffResult::ok_data("true")
|
||||
}
|
||||
|
||||
/// Get historical query by offset (0 = most recent)
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn fff_get_historical_query(offset: u64) -> *mut FffResult {
|
||||
let project_path = {
|
||||
let file_picker = match FILE_PICKER.read() {
|
||||
Ok(f) => f,
|
||||
Err(_) => return FffResult::ok_data("null"),
|
||||
};
|
||||
match file_picker.as_ref() {
|
||||
Some(p) => p.base_path().to_path_buf(),
|
||||
None => return FffResult::ok_data("null"),
|
||||
}
|
||||
};
|
||||
|
||||
let query_tracker = match QUERY_TRACKER.read() {
|
||||
Ok(q) => q,
|
||||
Err(_) => return FffResult::ok_data("null"),
|
||||
};
|
||||
|
||||
let tracker = match query_tracker.as_ref() {
|
||||
Some(t) => t,
|
||||
None => return FffResult::ok_data("null"),
|
||||
};
|
||||
|
||||
match tracker.get_historical_query(&project_path, offset as usize) {
|
||||
Ok(Some(query)) => {
|
||||
let json = serde_json::to_string(&query).unwrap_or_else(|_| "null".to_string());
|
||||
FffResult::ok_data(&json)
|
||||
}
|
||||
Ok(None) => FffResult::ok_data("null"),
|
||||
Err(e) => FffResult::err(&format!("Failed to get historical query: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get health check information
|
||||
///
|
||||
/// # Safety
|
||||
/// `test_path` can be null or a valid null-terminated UTF-8 string
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_health_check(test_path: *const c_char) -> *mut FffResult {
|
||||
let test_path = unsafe { cstr_to_str(test_path) }
|
||||
.filter(|s| !s.is_empty())
|
||||
.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()),
|
||||
);
|
||||
|
||||
// Git info
|
||||
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));
|
||||
|
||||
// File picker info
|
||||
let mut picker_info = serde_json::Map::new();
|
||||
match FILE_PICKER.read() {
|
||||
Ok(guard) => {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
picker_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
picker_info.insert(
|
||||
"error".to_string(),
|
||||
serde_json::Value::String("Failed to acquire lock".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
health.insert(
|
||||
"file_picker".to_string(),
|
||||
serde_json::Value::Object(picker_info),
|
||||
);
|
||||
|
||||
// Frecency info
|
||||
let mut frecency_info = serde_json::Map::new();
|
||||
match FRECENCY.read() {
|
||||
Ok(guard) => {
|
||||
frecency_info.insert(
|
||||
"initialized".to_string(),
|
||||
serde_json::Value::Bool(guard.is_some()),
|
||||
);
|
||||
if let Some(ref frecency) = *guard
|
||||
&& let Ok(health_data) = frecency.get_health()
|
||||
{
|
||||
let mut db_health = serde_json::Map::new();
|
||||
db_health.insert(
|
||||
"path".to_string(),
|
||||
serde_json::Value::String(health_data.path),
|
||||
);
|
||||
db_health.insert(
|
||||
"disk_size".to_string(),
|
||||
serde_json::Value::Number(health_data.disk_size.into()),
|
||||
);
|
||||
frecency_info.insert(
|
||||
"db_healthcheck".to_string(),
|
||||
serde_json::Value::Object(db_health),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
frecency_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
}
|
||||
health.insert(
|
||||
"frecency".to_string(),
|
||||
serde_json::Value::Object(frecency_info),
|
||||
);
|
||||
|
||||
// Query tracker info
|
||||
let mut query_info = serde_json::Map::new();
|
||||
match QUERY_TRACKER.read() {
|
||||
Ok(guard) => {
|
||||
query_info.insert(
|
||||
"initialized".to_string(),
|
||||
serde_json::Value::Bool(guard.is_some()),
|
||||
);
|
||||
if let Some(ref tracker) = *guard
|
||||
&& let Ok(health_data) = tracker.get_health()
|
||||
{
|
||||
let mut db_health = serde_json::Map::new();
|
||||
db_health.insert(
|
||||
"path".to_string(),
|
||||
serde_json::Value::String(health_data.path),
|
||||
);
|
||||
db_health.insert(
|
||||
"disk_size".to_string(),
|
||||
serde_json::Value::Number(health_data.disk_size.into()),
|
||||
);
|
||||
query_info.insert(
|
||||
"db_healthcheck".to_string(),
|
||||
serde_json::Value::Object(db_health),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
query_info.insert("initialized".to_string(), serde_json::Value::Bool(false));
|
||||
}
|
||||
}
|
||||
health.insert(
|
||||
"query_tracker".to_string(),
|
||||
serde_json::Value::Object(query_info),
|
||||
);
|
||||
|
||||
match serde_json::to_string(&health) {
|
||||
Ok(json) => FffResult::ok_data(&json),
|
||||
Err(e) => FffResult::err(&format!("Failed to serialize health check: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a result returned by any fff_* function
|
||||
///
|
||||
/// # Safety
|
||||
/// `result_ptr` must be a valid pointer returned by a fff_* function
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_free_result(result_ptr: *mut FffResult) {
|
||||
if result_ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let result = Box::from_raw(result_ptr);
|
||||
if !result.data.is_null() {
|
||||
drop(CString::from_raw(result.data));
|
||||
}
|
||||
if !result.error.is_null() {
|
||||
drop(CString::from_raw(result.error));
|
||||
}
|
||||
// Box will be dropped here, freeing the FffResult struct
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a string returned by fff_* functions
|
||||
///
|
||||
/// # Safety
|
||||
/// `s` must be a valid C string allocated by this library
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn fff_free_string(s: *mut c_char) {
|
||||
unsafe {
|
||||
if !s.is_null() {
|
||||
drop(CString::from_raw(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
[package]
|
||||
name = "fff-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "High-performance file finder core library"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["rlib", "staticlib", "cdylib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
ahash = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff-query-parser = { path = "../fff-query-parser" }
|
||||
|
||||
# External dependencies
|
||||
blake3 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
heed = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
neo_frizbee = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
notify-debouncer-full = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = { version = "1.2.8" }
|
||||
|
||||
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
tempfile = "3.8"
|
||||
@@ -0,0 +1,16 @@
|
||||
fn main() {
|
||||
// On Windows MSVC, explicitly link the C runtime libraries.
|
||||
// This is needed because Zig-compiled static libraries (zlob) don't emit
|
||||
// /DEFAULTLIB directives for the MSVC CRT. Without this, symbols like
|
||||
// strcmp, memcpy, memchr etc. from vendored C libraries (libgit2, lmdb)
|
||||
// are unresolved when linking the cdylib.
|
||||
//
|
||||
// We link both msvcrt (classic CRT) and ucrt (Universal CRT where memchr,
|
||||
// strcmp etc. live on newer MSVC/ARM64 targets).
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if target.contains("windows") && target.contains("msvc") {
|
||||
println!("cargo:rustc-link-lib=msvcrt");
|
||||
println!("cargo:rustc-link-lib=ucrt");
|
||||
println!("cargo:rustc-link-lib=vcruntime");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Constraint filtering engine for fff.
|
||||
//!
|
||||
//! This module provides the core constraint application logic that filters items
|
||||
//! based on parsed query constraints (extensions, path segments, globs, git status, etc.).
|
||||
//!
|
||||
//! The filtering is generic over the [`Constrainable`] trait, allowing reuse across
|
||||
//! different search modes (file picker, live grep, etc.).
|
||||
|
||||
use ahash::AHashSet;
|
||||
use fff_query_parser::{Constraint, GitStatusFilter};
|
||||
use smallvec::SmallVec;
|
||||
use zlob::{ZlobFlags, zlob_match_paths};
|
||||
|
||||
use crate::git::is_modified_status;
|
||||
|
||||
/// Minimum item count before switching to parallel iteration with rayon.
|
||||
/// Below this threshold, the overhead of thread pool dispatch outweighs the benefit.
|
||||
const PAR_THRESHOLD: usize = 10_000;
|
||||
|
||||
/// Trait for items that can be filtered by constraints.
|
||||
/// Implement this for any searchable item type (files, grep results, etc.).
|
||||
pub trait Constrainable {
|
||||
/// The file's relative path (e.g. "src/main.rs")
|
||||
fn relative_path(&self) -> &str;
|
||||
|
||||
/// The file's lowercased relative path for case-insensitive matching
|
||||
fn relative_path_lower(&self) -> &str;
|
||||
|
||||
/// The file name component (e.g. "main.rs")
|
||||
fn file_name(&self) -> &str;
|
||||
|
||||
/// The git status of this item, if available
|
||||
fn git_status(&self) -> Option<git2::Status>;
|
||||
}
|
||||
|
||||
/// Check if file extension matches (without allocation)
|
||||
#[inline]
|
||||
pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
|
||||
if file_name.len() <= ext.len() + 1 {
|
||||
return false;
|
||||
}
|
||||
let start = file_name.len() - ext.len() - 1;
|
||||
file_name.as_bytes().get(start) == Some(&b'.')
|
||||
&& file_name[start + 1..].eq_ignore_ascii_case(ext)
|
||||
}
|
||||
|
||||
/// Check if path contains segment (without allocation)
|
||||
#[inline]
|
||||
pub fn path_contains_segment(path: &str, segment: &str) -> bool {
|
||||
let path_bytes = path.as_bytes();
|
||||
let segment_len = segment.len();
|
||||
|
||||
// Check segment/ at start
|
||||
if path.len() > segment_len
|
||||
&& path_bytes.get(segment_len) == Some(&b'/')
|
||||
&& path[..segment_len].eq_ignore_ascii_case(segment)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check /segment/ anywhere using byte scanning
|
||||
if path.len() < segment_len + 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
for i in 0..path.len().saturating_sub(segment_len + 1) {
|
||||
if path_bytes[i] == b'/' {
|
||||
let start = i + 1;
|
||||
let end = start + segment_len;
|
||||
if end < path.len()
|
||||
&& path_bytes[end] == b'/'
|
||||
&& path[start..end].eq_ignore_ascii_case(segment)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if an item at given index matches a constraint (single-pass friendly, allocation-free)
|
||||
#[inline]
|
||||
fn item_matches_constraint_at_index<T: Constrainable>(
|
||||
item: &T,
|
||||
item_index: usize,
|
||||
constraint: &Constraint<'_>,
|
||||
glob_results: &[(bool, AHashSet<usize>)],
|
||||
glob_idx: &mut usize,
|
||||
negate: bool,
|
||||
) -> bool {
|
||||
let matches = match constraint {
|
||||
Constraint::Extension(ext) => file_has_extension(item.file_name(), ext),
|
||||
Constraint::Glob(_) => {
|
||||
let result = glob_results
|
||||
.get(*glob_idx)
|
||||
.map(|(is_neg, set)| {
|
||||
let matched = set.contains(&item_index);
|
||||
if *is_neg { !matched } else { matched }
|
||||
})
|
||||
.unwrap_or(true);
|
||||
*glob_idx += 1;
|
||||
return if negate { !result } else { result };
|
||||
}
|
||||
Constraint::PathSegment(segment) => path_contains_segment(item.relative_path(), segment),
|
||||
Constraint::GitStatus(status_filter) => match (item.git_status(), status_filter) {
|
||||
(Some(status), GitStatusFilter::Modified) => is_modified_status(status),
|
||||
(Some(status), GitStatusFilter::Untracked) => status.contains(git2::Status::WT_NEW),
|
||||
(Some(status), GitStatusFilter::Staged) => status.intersects(
|
||||
git2::Status::INDEX_NEW
|
||||
| git2::Status::INDEX_MODIFIED
|
||||
| git2::Status::INDEX_DELETED
|
||||
| git2::Status::INDEX_RENAMED
|
||||
| git2::Status::INDEX_TYPECHANGE,
|
||||
),
|
||||
(Some(status), GitStatusFilter::Unmodified) => status.is_empty(),
|
||||
(None, GitStatusFilter::Unmodified) => true,
|
||||
(None, _) => false,
|
||||
},
|
||||
Constraint::Not(inner) => {
|
||||
return item_matches_constraint_at_index(
|
||||
item,
|
||||
item_index,
|
||||
inner,
|
||||
glob_results,
|
||||
glob_idx,
|
||||
!negate,
|
||||
);
|
||||
}
|
||||
|
||||
// only works with negation
|
||||
Constraint::Text(text) => item.relative_path_lower().contains(text),
|
||||
|
||||
// Parts and Exclude are handled at a higher level
|
||||
Constraint::Parts(_) | Constraint::Exclude(_) | Constraint::FileType(_) => true,
|
||||
};
|
||||
|
||||
if negate { !matches } else { matches }
|
||||
}
|
||||
|
||||
/// Apply constraint-based prefiltering in a single pass over all items.
|
||||
/// Returns `None` if no constraints are present, `Some(filtered)` otherwise.
|
||||
/// Multiple extension constraints (*.rs *.ts) are combined with OR logic.
|
||||
/// All other constraints are combined with AND logic.
|
||||
///
|
||||
/// Uses parallel iteration via rayon when the item count exceeds [`PAR_THRESHOLD`].
|
||||
pub fn apply_constraints<'a, T: Constrainable + Sync>(
|
||||
items: &'a [T],
|
||||
constraints: &[Constraint<'_>],
|
||||
) -> Option<Vec<&'a T>> {
|
||||
if constraints.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Separate extension constraints from other constraints — they use OR logic
|
||||
let mut extensions: SmallVec<[&str; 8]> = SmallVec::new();
|
||||
let mut other_constraints: SmallVec<[&Constraint<'_>; 8]> = SmallVec::new();
|
||||
|
||||
for constraint in constraints {
|
||||
match constraint {
|
||||
Constraint::Extension(ext) => extensions.push(ext),
|
||||
_ => other_constraints.push(constraint),
|
||||
}
|
||||
}
|
||||
|
||||
// Only collect paths if we have glob constraints (expensive)
|
||||
let has_globs = other_constraints
|
||||
.iter()
|
||||
.any(|c| matches!(c, Constraint::Glob(_) | Constraint::Not(_)));
|
||||
|
||||
let glob_results = if has_globs {
|
||||
let paths: Vec<&str> = items.iter().map(|f| f.relative_path()).collect();
|
||||
precompute_glob_matches(&other_constraints, &paths)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let matches_constraints = |i: usize, item: &T| -> bool {
|
||||
if !extensions.is_empty()
|
||||
&& !extensions
|
||||
.iter()
|
||||
.any(|ext| file_has_extension(item.file_name(), ext))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut glob_idx = 0;
|
||||
other_constraints.iter().all(|constraint| {
|
||||
item_matches_constraint_at_index(
|
||||
item,
|
||||
i,
|
||||
constraint,
|
||||
&glob_results,
|
||||
&mut glob_idx,
|
||||
false,
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
let filtered: Vec<&T> = if items.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
items
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, item)| matches_constraints(*i, item))
|
||||
.map(|(_, item)| item)
|
||||
.collect()
|
||||
} else {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, item)| matches_constraints(*i, item))
|
||||
.map(|(_, item)| item)
|
||||
.collect()
|
||||
};
|
||||
|
||||
Some(filtered)
|
||||
}
|
||||
|
||||
fn precompute_glob_matches<'a>(
|
||||
constraints: &[&Constraint<'a>],
|
||||
paths: &[&str],
|
||||
) -> Vec<(bool, AHashSet<usize>)> {
|
||||
let mut results = Vec::new();
|
||||
for constraint in constraints {
|
||||
collect_glob_indices(constraint, paths, &mut results, false);
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn collect_glob_indices<'a>(
|
||||
constraint: &Constraint<'a>,
|
||||
paths: &[&str],
|
||||
results: &mut Vec<(bool, AHashSet<usize>)>,
|
||||
is_negated: bool,
|
||||
) {
|
||||
match constraint {
|
||||
Constraint::Glob(pattern) => {
|
||||
if let Ok(Some(matches)) = zlob_match_paths(pattern, paths, ZlobFlags::RECOMMENDED) {
|
||||
let matched_set: AHashSet<usize> =
|
||||
matches.iter().map(|s| s.as_ptr() as usize).collect();
|
||||
|
||||
let indices: AHashSet<usize> = if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matched_set.contains(&(p.as_ptr() as usize)))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
};
|
||||
results.push((is_negated, indices));
|
||||
} else {
|
||||
results.push((is_negated, AHashSet::new()));
|
||||
}
|
||||
}
|
||||
Constraint::Not(inner) => {
|
||||
collect_glob_indices(inner, paths, results, !is_negated);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_file_has_extension() {
|
||||
assert!(file_has_extension("file.rs", "rs"));
|
||||
assert!(file_has_extension("file.RS", "rs")); // case-insensitive
|
||||
assert!(file_has_extension("file.test.rs", "rs"));
|
||||
assert!(file_has_extension("a.rs", "rs"));
|
||||
|
||||
assert!(!file_has_extension("file.tsx", "rs"));
|
||||
assert!(!file_has_extension("rs", "rs")); // too short
|
||||
assert!(!file_has_extension(".rs", "rs")); // just extension
|
||||
assert!(!file_has_extension("file.rsx", "rs")); // different extension
|
||||
assert!(!file_has_extension("filers", "rs")); // no dot
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_contains_segment() {
|
||||
// Segment at start
|
||||
assert!(path_contains_segment("src/lib.rs", "src"));
|
||||
assert!(path_contains_segment("SRC/lib.rs", "src")); // case-insensitive
|
||||
|
||||
// Segment in middle
|
||||
assert!(path_contains_segment("app/src/lib.rs", "src"));
|
||||
assert!(path_contains_segment("app/SRC/lib.rs", "src"));
|
||||
|
||||
// Multiple levels
|
||||
assert!(path_contains_segment("core/workflow/src/main.rs", "src"));
|
||||
assert!(path_contains_segment(
|
||||
"core/workflow/src/main.rs",
|
||||
"workflow"
|
||||
));
|
||||
assert!(path_contains_segment("core/workflow/src/main.rs", "core"));
|
||||
|
||||
// Should not match partial segments
|
||||
assert!(!path_contains_segment("source/lib.rs", "src"));
|
||||
assert!(!path_contains_segment("mysrc/lib.rs", "src"));
|
||||
|
||||
// Should not match filename
|
||||
assert!(!path_contains_segment("lib/src", "src"));
|
||||
|
||||
// Edge cases
|
||||
assert!(!path_contains_segment("", "src"));
|
||||
assert!(!path_contains_segment("src", "src")); // no trailing slash
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::error::Result;
|
||||
|
||||
/// Health information about a database
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DbHealth {
|
||||
/// Path to the database file
|
||||
pub path: String,
|
||||
/// Size on disk in bytes
|
||||
pub disk_size: u64,
|
||||
/// Entry counts by table name
|
||||
pub entry_counts: Vec<(&'static str, u64)>,
|
||||
}
|
||||
|
||||
pub trait DbHealthChecker {
|
||||
fn get_env(&self) -> &heed::Env;
|
||||
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>>;
|
||||
|
||||
fn get_health(&self) -> Result<DbHealth> {
|
||||
let env = self.get_env();
|
||||
|
||||
let size = env.real_disk_size().map_err(crate::error::Error::EnvOpen)?;
|
||||
let path = env.path().to_string_lossy().to_string();
|
||||
let entry_counts = self.count_entries()?;
|
||||
|
||||
Ok(DbHealth {
|
||||
path,
|
||||
disk_size: size,
|
||||
entry_counts,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub enum Error {
|
||||
AcquireFrecencyLock,
|
||||
#[error("Failed to acquire lock for items by provider")]
|
||||
AcquireItemLock,
|
||||
#[error("Failed to acquire lock for path cache")]
|
||||
AcquirePathCacheLock,
|
||||
#[error("Failed to create directory: {0}")]
|
||||
CreateDir(#[from] std::io::Error),
|
||||
#[error("Failed to open frecency database env: {0}")]
|
||||
@@ -43,13 +45,4 @@ pub enum Error {
|
||||
Git(#[from] git2::Error),
|
||||
}
|
||||
|
||||
impl From<Error> for mlua::Error {
|
||||
fn from(value: Error) -> Self {
|
||||
let string_value = value.to_string();
|
||||
|
||||
::tracing::error!(string_value);
|
||||
mlua::Error::RuntimeError(string_value)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -2,10 +2,10 @@ use crate::background_watcher::BackgroundWatcher;
|
||||
use crate::error::Error;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
use crate::location::parse_location;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use crate::score::match_and_score_files;
|
||||
use crate::types::{FileItem, PaginationArgs, ScoringContext, SearchResult};
|
||||
use fff_query_parser::FFFQuery;
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use rayon::prelude::*;
|
||||
use std::fmt::Debug;
|
||||
@@ -175,14 +175,29 @@ impl FilePicker {
|
||||
Ok(picker)
|
||||
}
|
||||
|
||||
/// Perform fuzzy search on files with a pre-parsed query.
|
||||
///
|
||||
/// The query should be parsed using `QueryParser::parse()` before calling this function.
|
||||
/// This allows the caller to handle location parsing and other preprocessing.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `files` - Slice of files to search
|
||||
/// * `query` - The raw query string (used for max_typos calculation and debugging)
|
||||
/// * `parsed` - Pre-parsed query result (can be None for simple single-token queries)
|
||||
/// * `options` - Search options including pagination, threading, and scoring parameters
|
||||
///
|
||||
/// # Returns
|
||||
/// SearchResult containing matched files, scores, and location information
|
||||
pub fn fuzzy_search<'a>(
|
||||
files: &'a [FileItem],
|
||||
query: &'a str,
|
||||
parsed: Option<FFFQuery<'a>>,
|
||||
options: FuzzySearchOptions<'a>,
|
||||
) -> SearchResult<'a> {
|
||||
let max_threads = options.max_threads.max(1);
|
||||
debug!(
|
||||
?query,
|
||||
parsed_is_some = parsed.is_some(),
|
||||
pagination = ?options.pagination,
|
||||
?max_threads,
|
||||
current_file = ?options.current_file,
|
||||
@@ -190,13 +205,26 @@ impl FilePicker {
|
||||
);
|
||||
|
||||
let total_files = files.len();
|
||||
let (query, location) = parse_location(query);
|
||||
|
||||
// Extract location from parsed query
|
||||
let location = parsed.as_ref().and_then(|p| p.location);
|
||||
|
||||
// Get effective query for max_typos calculation (without location suffix)
|
||||
let effective_query = match &parsed {
|
||||
Some(p) => match &p.fuzzy_query {
|
||||
fff_query_parser::FuzzyQuery::Text(t) => *t,
|
||||
fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
|
||||
_ => query.trim(),
|
||||
},
|
||||
None => query.trim(),
|
||||
};
|
||||
|
||||
// small queries with a large number of results can match absolutely everything
|
||||
let max_typos = (query.len() as u16 / 4).clamp(2, 6);
|
||||
let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
|
||||
|
||||
let context = ScoringContext {
|
||||
query,
|
||||
raw_query: query,
|
||||
parsed_query: parsed,
|
||||
project_path: options.project_path,
|
||||
max_typos,
|
||||
max_threads,
|
||||
@@ -209,7 +237,6 @@ impl FilePicker {
|
||||
|
||||
let time = std::time::Instant::now();
|
||||
|
||||
// Match, score, and paginate files (all done in sort_and_truncate)
|
||||
let (items, scores, total_matched) = match_and_score_files(files, &context);
|
||||
|
||||
debug!(
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use crate::{error::Error, git::is_modified_status};
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use heed::{
|
||||
@@ -26,6 +27,19 @@ const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(1, 60 * 60 * 24 * 7), // 1 week
|
||||
];
|
||||
|
||||
impl DbHealthChecker for FrecencyTracker {
|
||||
fn get_env(&self) -> &heed::Env {
|
||||
&self.env
|
||||
}
|
||||
|
||||
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let count = self.db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
|
||||
Ok(vec![("absolute_frecency_entries", count)])
|
||||
}
|
||||
}
|
||||
|
||||
impl FrecencyTracker {
|
||||
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
@@ -0,0 +1,39 @@
|
||||
//! fff-core - High-performance file finder library
|
||||
//!
|
||||
//! This crate provides the core file indexing and fuzzy search functionality.
|
||||
//! It maintains global state for the file picker, frecency tracker, and query tracker.
|
||||
|
||||
mod background_watcher;
|
||||
pub mod constraints;
|
||||
mod db_healthcheck;
|
||||
mod error;
|
||||
pub mod file_picker;
|
||||
pub mod frecency;
|
||||
pub mod git;
|
||||
pub mod path_utils;
|
||||
pub mod query_tracker;
|
||||
pub mod score;
|
||||
mod sort_buffer;
|
||||
pub mod types;
|
||||
|
||||
use file_picker::FilePicker;
|
||||
use frecency::FrecencyTracker;
|
||||
use once_cell::sync::Lazy;
|
||||
use query_tracker::QueryTracker;
|
||||
use std::sync::RwLock;
|
||||
|
||||
// Global state - same pattern as fff-nvim
|
||||
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub static QUERY_TRACKER: Lazy<RwLock<Option<QueryTracker>>> = Lazy::new(|| RwLock::new(None));
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use error::{Error, Result};
|
||||
pub use file_picker::{FuzzySearchOptions, ScanProgress};
|
||||
pub use types::{FileItem, PaginationArgs, Score, ScoringContext, SearchResult};
|
||||
|
||||
// Re-export query parser types (including Location which moved there)
|
||||
pub use fff_query_parser::{
|
||||
Constraint, FFFQuery, FuzzyQuery, Location, QueryParser, location::parse_location,
|
||||
};
|
||||
@@ -1,3 +1,24 @@
|
||||
//! Path utility functions for file picker scoring
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Canonicalize a path, resolving symlinks and producing an absolute path.
|
||||
///
|
||||
/// On Windows, uses `dunce::canonicalize` to avoid the `\\?\` extended-length path prefix
|
||||
/// that `std::fs::canonicalize` produces. Neovim cannot open paths with this prefix.
|
||||
/// On other platforms, delegates directly to `std::fs::canonicalize`.
|
||||
#[cfg(windows)]
|
||||
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
dunce::canonicalize(path)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
std::fs::canonicalize(path)
|
||||
}
|
||||
|
||||
/// Calculate distance penalty based on directory proximity
|
||||
/// Returns a negative penalty score based on how far the candidate is from the current file
|
||||
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &str) -> i32 {
|
||||
let Some(ref current_path) = current_file else {
|
||||
return 0; // No penalty if no current file
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use crate::error::Error;
|
||||
use heed::types::Bytes;
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
@@ -34,6 +35,24 @@ pub struct QueryTracker {
|
||||
query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
}
|
||||
|
||||
impl DbHealthChecker for QueryTracker {
|
||||
fn get_env(&self) -> &Env {
|
||||
&self.env
|
||||
}
|
||||
|
||||
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
let count_queries = self.query_file_db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
let count_histories = self.query_history_db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
|
||||
Ok(vec![
|
||||
("query_file_entries", count_queries),
|
||||
("query_history_entries", count_histories),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
impl QueryTracker {
|
||||
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
@@ -1,64 +1,195 @@
|
||||
use crate::{
|
||||
constraints::apply_constraints,
|
||||
git::is_modified_status,
|
||||
path_utils::calculate_distance_penalty,
|
||||
sort_buffer::{sort_by_key_with_buffer, sort_with_buffer},
|
||||
types::{FileItem, Score, ScoringContext},
|
||||
};
|
||||
use fff_query_parser::FuzzyQuery;
|
||||
use neo_frizbee::Scoring;
|
||||
use rayon::prelude::*;
|
||||
use std::path::MAIN_SEPARATOR;
|
||||
|
||||
// like cow but better
|
||||
enum FileItems<'a> {
|
||||
/// All files — borrows the original owned slice, zero allocation.
|
||||
All(&'a [FileItem]),
|
||||
/// Filtered subset — owns references produced by constraint filtering.
|
||||
Filtered(Vec<&'a FileItem>),
|
||||
}
|
||||
|
||||
impl<'a> FileItems<'a> {
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
fn len(&self) -> usize {
|
||||
match self {
|
||||
FileItems::All(s) => s.len(),
|
||||
FileItems::Filtered(v) => v.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get(&self, index: usize) -> Option<&'a FileItem> {
|
||||
match self {
|
||||
FileItems::All(s) => s.get(index),
|
||||
FileItems::Filtered(v) => v.get(index).copied(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the haystack of relative paths (original casing) for fuzzy matching.
|
||||
/// neo_frizbee lowercases internally for comparison but preserves original casing
|
||||
/// for capitalization_bonus and matching_case_bonus scoring.
|
||||
fn relative_paths(&self) -> Vec<&'a str> {
|
||||
match self {
|
||||
FileItems::All(s) => s.iter().map(|f| f.relative_path.as_str()).collect(),
|
||||
FileItems::Filtered(v) => v.iter().map(|f| f.relative_path.as_str()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Index into the file list. Panics if out of bounds (like slice indexing).
|
||||
#[inline]
|
||||
fn index(&self, index: usize) -> &'a FileItem {
|
||||
match self {
|
||||
FileItems::All(s) => &s[index],
|
||||
FileItems::Filtered(v) => v[index],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Match files against all fuzzy parts.
|
||||
/// Single part: use optimized batch matching.
|
||||
/// Multiple parts: each part must match, scores are summed (Nucleo-style).
|
||||
/// Parts with less than 2 characters are skipped.
|
||||
fn match_fuzzy_parts(
|
||||
fuzzy_parts: &[&str],
|
||||
working_files: &FileItems<'_>,
|
||||
options: &neo_frizbee::Config,
|
||||
) -> Vec<neo_frizbee::Match> {
|
||||
if fuzzy_parts.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let haystack: Vec<&str> = working_files.relative_paths();
|
||||
|
||||
// Filter out parts that are too short (< 2 chars)
|
||||
let valid_parts: Vec<&str> = fuzzy_parts
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| p.len() >= 2)
|
||||
.collect();
|
||||
|
||||
if valid_parts.is_empty() {
|
||||
tracing::debug!("match_fuzzy_parts: no valid parts after filtering, returning empty");
|
||||
return vec![];
|
||||
}
|
||||
|
||||
if valid_parts.len() == 1 {
|
||||
let matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Multiple parts - match first part, then filter by remaining parts
|
||||
// TODO figure out if we can move this logic to my frizbee fork at least
|
||||
let mut matches = neo_frizbee::match_list(valid_parts[0], &haystack, options);
|
||||
for part in valid_parts[1..].iter() {
|
||||
let mut part_options = *options;
|
||||
part_options.max_typos = options.max_typos.map(|t| t.min(part.len() as u16));
|
||||
|
||||
matches = matches
|
||||
.into_iter()
|
||||
.filter_map(|mut m| {
|
||||
let path = haystack.get(m.index as usize)?;
|
||||
let part_matches = neo_frizbee::match_list(part, &[*path], &part_options);
|
||||
let part_match = part_matches.first()?;
|
||||
|
||||
// Sum scores
|
||||
let total = (m.score as u32).saturating_add(part_match.score as u32);
|
||||
m.score = total.min(u16::MAX as u32) as u16;
|
||||
Some(m)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if matches.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
matches
|
||||
}
|
||||
|
||||
pub fn match_and_score_files<'a>(
|
||||
files: &'a [FileItem],
|
||||
context: &ScoringContext,
|
||||
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
|
||||
if context.query.len() < 2 {
|
||||
return score_all_by_frecency(files, context);
|
||||
}
|
||||
|
||||
if files.is_empty() {
|
||||
return (vec![], vec![], 0);
|
||||
}
|
||||
|
||||
let has_uppercase_letter = context.query.chars().any(|c| c.is_uppercase());
|
||||
let parsed = &context.parsed_query;
|
||||
let working_files: FileItems<'a> = match parsed.as_ref().and_then(|p| {
|
||||
if p.constraints.is_empty() {
|
||||
None
|
||||
} else {
|
||||
apply_constraints(files, &p.constraints)
|
||||
}
|
||||
}) {
|
||||
Some(filtered) if !filtered.is_empty() => FileItems::Filtered(filtered),
|
||||
Some(_) => {
|
||||
return (vec![], vec![], 0);
|
||||
}
|
||||
None => FileItems::All(files),
|
||||
};
|
||||
|
||||
let query_trimmed: &str = context.raw_query.trim();
|
||||
let single_part_storage: [&str; 1] = [query_trimmed];
|
||||
|
||||
let fuzzy_parts: &[&str] = match parsed {
|
||||
None => {
|
||||
tracing::debug!("STEP 3: Query too short (<2 chars), returning frecency-sorted");
|
||||
if query_trimmed.len() < 2 {
|
||||
return score_filtered_by_frecency(&working_files, context);
|
||||
}
|
||||
&single_part_storage
|
||||
}
|
||||
Some(p) => match &p.fuzzy_query {
|
||||
FuzzyQuery::Text(t) if t.len() >= 2 => std::slice::from_ref(t),
|
||||
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts.as_slice(),
|
||||
_ => {
|
||||
return score_filtered_by_frecency(&working_files, context);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let has_uppercase = fuzzy_parts
|
||||
.iter()
|
||||
.any(|p| p.chars().any(|c| c.is_uppercase()));
|
||||
let query_contains_path_separator = fuzzy_parts.iter().any(|p| p.contains(MAIN_SEPARATOR));
|
||||
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(context.max_typos),
|
||||
sort: false,
|
||||
scoring: Scoring {
|
||||
capitalization_bonus: if has_uppercase_letter { 8 } else { 0 },
|
||||
matching_case_bonus: if has_uppercase_letter { 4 } else { 0 },
|
||||
capitalization_bonus: if has_uppercase { 8 } else { 0 },
|
||||
matching_case_bonus: if has_uppercase { 4 } else { 0 },
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let query_contains_path_separator = context.query.contains(MAIN_SEPARATOR);
|
||||
let haystack: Vec<&str> = files
|
||||
.iter()
|
||||
.map(|f| f.relative_path_lower.as_str())
|
||||
.collect();
|
||||
tracing::debug!(
|
||||
"Starting fuzzy search for query '{}' in {} files",
|
||||
context.query,
|
||||
haystack.len()
|
||||
);
|
||||
let path_matches = neo_frizbee::match_list(context.query, &haystack, &options);
|
||||
tracing::debug!(
|
||||
"Matched {} files for query '{}'",
|
||||
path_matches.len(),
|
||||
context.query
|
||||
);
|
||||
|
||||
// assume that filename should only match if the path matches
|
||||
// we should actually incorporate this bonus by getting this information from neo_frizbee directly
|
||||
// instead of spawning a separate matching process, but it's okay for the beta
|
||||
// Use sequential iteration - this is a simple filtering operation that's faster without Rayon overhead
|
||||
let path_matches = match_fuzzy_parts(fuzzy_parts, &working_files, &options);
|
||||
let primary_text = fuzzy_parts[0]; // Use first part for filename matching
|
||||
let haystack_of_filenames: Vec<&str> = path_matches
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
files
|
||||
working_files
|
||||
.get(m.index as usize)
|
||||
.map(|f| f.file_name_lower.as_str())
|
||||
.map(|f| f.file_name.as_str())
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -70,13 +201,13 @@ pub fn match_and_score_files<'a>(
|
||||
// Sequential matching is faster for small result sets (< 1000 matches)
|
||||
let mut list = if haystack_of_filenames.len() > 1000 {
|
||||
neo_frizbee::match_list_parallel(
|
||||
context.query,
|
||||
primary_text,
|
||||
&haystack_of_filenames,
|
||||
&options,
|
||||
context.max_threads,
|
||||
)
|
||||
} else {
|
||||
neo_frizbee::match_list(context.query, &haystack_of_filenames, &options)
|
||||
neo_frizbee::match_list(primary_text, &haystack_of_filenames, &options)
|
||||
};
|
||||
|
||||
// Sequential sort is faster for small lists
|
||||
@@ -95,7 +226,7 @@ pub fn match_and_score_files<'a>(
|
||||
.enumerate()
|
||||
.map(|(index, path_match)| {
|
||||
let file_idx = path_match.index as usize;
|
||||
let file = &files[file_idx];
|
||||
let file = working_files.index(file_idx);
|
||||
|
||||
let mut base_score = path_match.score as i32;
|
||||
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
|
||||
@@ -223,36 +354,39 @@ fn is_special_entry_point_file(filename: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn score_all_by_frecency<'a>(
|
||||
files: &'a [FileItem],
|
||||
/// Score files by frecency when we have a filtered list (prefiltered by constraints)
|
||||
fn score_filtered_by_frecency<'a>(
|
||||
files: &FileItems<'a>,
|
||||
context: &ScoringContext,
|
||||
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
|
||||
let results: Vec<_> = files
|
||||
.par_iter()
|
||||
.map(|file| {
|
||||
let total_frecency_score = file.access_frecency_score as i32
|
||||
+ (file.modification_frecency_score as i32).saturating_mul(4);
|
||||
let score_file = |file: &'a FileItem| {
|
||||
let total_frecency_score = file.access_frecency_score as i32
|
||||
+ (file.modification_frecency_score as i32).saturating_mul(4);
|
||||
|
||||
let current_file_penalty =
|
||||
calculate_current_file_penalty(file, total_frecency_score, context);
|
||||
let total = total_frecency_score.saturating_add(current_file_penalty);
|
||||
let current_file_penalty =
|
||||
calculate_current_file_penalty(file, total_frecency_score, context);
|
||||
let total = total_frecency_score.saturating_add(current_file_penalty);
|
||||
|
||||
let score = Score {
|
||||
total,
|
||||
base_score: 0,
|
||||
filename_bonus: 0,
|
||||
distance_penalty: 0,
|
||||
special_filename_bonus: 0,
|
||||
combo_match_boost: 0,
|
||||
current_file_penalty,
|
||||
frecency_boost: total_frecency_score,
|
||||
exact_match: false,
|
||||
match_type: "frecency",
|
||||
};
|
||||
let score = Score {
|
||||
total,
|
||||
base_score: 0,
|
||||
filename_bonus: 0,
|
||||
distance_penalty: 0,
|
||||
special_filename_bonus: 0,
|
||||
combo_match_boost: 0,
|
||||
current_file_penalty,
|
||||
frecency_boost: total_frecency_score,
|
||||
exact_match: false,
|
||||
match_type: "frecency",
|
||||
};
|
||||
|
||||
(file, score)
|
||||
})
|
||||
.collect();
|
||||
(file, score)
|
||||
};
|
||||
|
||||
let results: Vec<_> = match files {
|
||||
FileItems::All(s) => s.par_iter().map(&score_file).collect(),
|
||||
FileItems::Filtered(v) => v.iter().map(|&file| score_file(file)).collect(),
|
||||
};
|
||||
|
||||
sort_and_paginate(results, context)
|
||||
}
|
||||
@@ -402,7 +536,8 @@ mod tests {
|
||||
.collect();
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
raw_query: "test",
|
||||
parsed_query: None,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
@@ -450,7 +585,8 @@ mod tests {
|
||||
.collect();
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
raw_query: "test",
|
||||
parsed_query: None,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
@@ -496,7 +632,8 @@ mod tests {
|
||||
.collect();
|
||||
|
||||
let context = ScoringContext {
|
||||
query: "test",
|
||||
raw_query: "test",
|
||||
parsed_query: None,
|
||||
max_threads: 1,
|
||||
max_typos: 2,
|
||||
current_file: None,
|
||||
@@ -523,3 +660,64 @@ mod tests {
|
||||
assert_eq!(items[2].relative_path, "file3.rs");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod multi_part_tests {
|
||||
#[test]
|
||||
fn test_single_path_matching() {
|
||||
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs";
|
||||
|
||||
// Test with max_typos = 2 (safe for short needles)
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(2),
|
||||
sort: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Test "aipart" matching
|
||||
let matches = neo_frizbee::match_list("aipart", &[path], &options);
|
||||
println!("'aipart' matches (max_typos=2): {:?}", matches);
|
||||
assert!(!matches.is_empty(), "'aipart' should match the path");
|
||||
|
||||
// Test "core" matching
|
||||
let matches = neo_frizbee::match_list("core", &[path], &options);
|
||||
println!("'core' matches (max_typos=2): {:?}", matches);
|
||||
assert!(!matches.is_empty(), "'core' should match the path");
|
||||
|
||||
// Test "co" matching - need max_typos <= needle.len()
|
||||
let co_options = neo_frizbee::Config {
|
||||
max_typos: Some(2), // Safe: 2 <= len("co") = 2
|
||||
..options
|
||||
};
|
||||
let matches = neo_frizbee::match_list("co", &[path], &co_options);
|
||||
println!("'co' matches (max_typos=2): {:?}", matches);
|
||||
assert!(!matches.is_empty(), "'co' should match the path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lowercase_path_matching() {
|
||||
// The actual paths are lowercased
|
||||
let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs".to_lowercase();
|
||||
|
||||
let options = neo_frizbee::Config {
|
||||
prefilter: true,
|
||||
max_typos: Some(2),
|
||||
sort: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Test "co" matching on lowercase path
|
||||
let matches = neo_frizbee::match_list("co", &[path.as_str()], &options);
|
||||
println!("'co' matches lowercase path (max_typos=2): {:?}", matches);
|
||||
assert!(!matches.is_empty(), "'co' should match the lowercase path");
|
||||
|
||||
// Test "core" matching on lowercase path
|
||||
let matches = neo_frizbee::match_list("core", &[path.as_str()], &options);
|
||||
println!("'core' matches lowercase path (max_typos=2): {:?}", matches);
|
||||
assert!(
|
||||
!matches.is_empty(),
|
||||
"'core' should match the lowercase path"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::constraints::Constrainable;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileItem {
|
||||
pub path: PathBuf,
|
||||
pub relative_path: String,
|
||||
pub relative_path_lower: String,
|
||||
pub file_name: String,
|
||||
pub file_name_lower: String,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
pub access_frecency_score: i64,
|
||||
pub modification_frecency_score: i64,
|
||||
pub total_frecency_score: i64,
|
||||
pub git_status: Option<git2::Status>,
|
||||
}
|
||||
|
||||
impl Constrainable for FileItem {
|
||||
#[inline]
|
||||
fn relative_path(&self) -> &str {
|
||||
&self.relative_path
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn relative_path_lower(&self) -> &str {
|
||||
&self.relative_path_lower
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn file_name(&self) -> &str {
|
||||
&self.file_name
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn git_status(&self) -> Option<git2::Status> {
|
||||
self.git_status
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Score {
|
||||
pub total: i32,
|
||||
pub base_score: i32,
|
||||
pub filename_bonus: i32,
|
||||
pub special_filename_bonus: i32,
|
||||
pub frecency_boost: i32,
|
||||
pub distance_penalty: i32,
|
||||
pub current_file_penalty: i32,
|
||||
pub combo_match_boost: i32,
|
||||
pub exact_match: bool,
|
||||
pub match_type: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PaginationArgs {
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
/// Context for scoring files during search.
|
||||
///
|
||||
/// The `parsed_query` field contains the pre-parsed query with constraints,
|
||||
/// fuzzy parts, and location information. Parsing is done once at the API
|
||||
/// boundary and passed through.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoringContext<'a> {
|
||||
/// The original raw query string (for compatibility and debugging)
|
||||
pub raw_query: &'a str,
|
||||
/// Pre-parsed query containing constraints, fuzzy parts, and location
|
||||
pub parsed_query: Option<FFFQuery<'a>>,
|
||||
pub project_path: Option<&'a Path>,
|
||||
pub current_file: Option<&'a str>,
|
||||
pub max_typos: u16,
|
||||
pub max_threads: usize,
|
||||
pub last_same_query_match: Option<&'a QueryMatchEntry>,
|
||||
pub combo_boost_score_multiplier: i32,
|
||||
pub min_combo_count: u32,
|
||||
pub pagination: PaginationArgs,
|
||||
}
|
||||
|
||||
impl<'a> ScoringContext<'a> {
|
||||
/// Get the effective fuzzy query string for matching.
|
||||
/// Returns the first fuzzy part, or the raw query if no parsing was done.
|
||||
pub fn effective_query(&self) -> &'a str {
|
||||
match &self.parsed_query {
|
||||
Some(p) => match &p.fuzzy_query {
|
||||
FuzzyQuery::Text(t) => t,
|
||||
FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
|
||||
_ => self.raw_query.trim(),
|
||||
},
|
||||
None => self.raw_query.trim(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SearchResult<'a> {
|
||||
pub items: Vec<&'a FileItem>,
|
||||
pub scores: Vec<Score>,
|
||||
pub total_matched: usize,
|
||||
pub total_files: usize,
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
[package]
|
||||
name = "fff-nvim"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "test_watcher"
|
||||
path = "src/bin/test_watcher.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "jemalloc_profile"
|
||||
path = "src/bin/jemalloc_profile.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "search_profiler"
|
||||
path = "src/bin/search_profiler.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bench_search_only"
|
||||
path = "src/bin/bench_search_only.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
ahash = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff-core = { path = "../fff-core" }
|
||||
fff-query-parser = { path = "../fff-query-parser" }
|
||||
|
||||
# External dependencies
|
||||
blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
ctrlc = "3.4.2"
|
||||
dirs = "5.0"
|
||||
git2 = { workspace = true }
|
||||
glidesort = "0.1"
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
mimalloc = "0.1.47"
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = { version = "0.7.2" }
|
||||
notify = "8.1.0"
|
||||
notify-debouncer-full = "0.6"
|
||||
once_cell = "1.20.2"
|
||||
pathdiff = "0.2.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
smartstring = { version = "1.0.1", features = ["serde"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zlob = "1.2.8"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
tempfile = "3.8"
|
||||
|
||||
[[bench]]
|
||||
name = "indexing_and_search"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "query_tracker_bench"
|
||||
harness = false
|
||||
|
||||
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
|
||||
# On Windows, git2 uses the native SChannel TLS backend
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
@@ -121,8 +121,7 @@ fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
let canonical_path = fff_core::path_utils::canonicalize(&big_repo_path)
|
||||
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
|
||||
eprintln!(" Path: {:?}", canonical_path);
|
||||
|
||||
@@ -166,7 +165,7 @@ fn bench_indexing(c: &mut Criterion) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = match big_repo_path.canonicalize() {
|
||||
let canonical_path = match fff_core::path_utils::canonicalize(&big_repo_path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("⚠ Failed to canonicalize path: {}", e);
|
||||
@@ -1,5 +1,6 @@
|
||||
/// Simple search profiler that directly uses scan_filesystem without background thread overhead
|
||||
use fff_nvim::file_picker::FilePicker;
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
@@ -12,9 +13,8 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
.expect("Failed to canonicalize path");
|
||||
let canonical_path =
|
||||
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
|
||||
|
||||
eprintln!("Loading files from: {:?}", canonical_path);
|
||||
|
||||
@@ -37,7 +37,7 @@ fn main() {
|
||||
let relative_path = relative.to_string_lossy().into_owned();
|
||||
let file_name = entry.file_name().to_string_lossy().into_owned();
|
||||
|
||||
files.push(fff_nvim::types::FileItem {
|
||||
files.push(FileItem {
|
||||
path,
|
||||
relative_path_lower: relative_path.to_lowercase(),
|
||||
relative_path,
|
||||
@@ -87,17 +87,20 @@ fn main() {
|
||||
let mut match_count = 0;
|
||||
|
||||
for _ in 0..iterations {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(query);
|
||||
let results = FilePicker::fuzzy_search(
|
||||
&files,
|
||||
query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
parsed,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
@@ -1,4 +1,5 @@
|
||||
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use std::env;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
@@ -83,17 +84,20 @@ fn test_search_memory_pattern(
|
||||
let (result_count, _total_matched) = {
|
||||
let file_picker_guard = FILE_PICKER.read().unwrap();
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(&query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
&query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
parsed,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 1 + (i % 4),
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 50 + (i % 50),
|
||||
},
|
||||
@@ -1,5 +1,5 @@
|
||||
use fff_nvim::FILE_PICKER;
|
||||
use fff_nvim::file_picker::FilePicker;
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FILE_PICKER, FileItem, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Wait for background scan to complete
|
||||
@@ -58,7 +58,7 @@ fn init_file_picker(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Get files snapshot from global state
|
||||
fn get_files() -> Result<Vec<fff_nvim::types::FileItem>, String> {
|
||||
fn get_files() -> Result<Vec<FileItem>, String> {
|
||||
let picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire read lock")?;
|
||||
@@ -79,9 +79,8 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canonical_path = big_repo_path
|
||||
.canonicalize()
|
||||
.expect("Failed to canonicalize path");
|
||||
let canonical_path =
|
||||
fff_core::path_utils::canonicalize(&big_repo_path).expect("Failed to canonicalize path");
|
||||
|
||||
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
|
||||
init_file_picker(&canonical_path.to_string_lossy()).expect("Failed to init FilePicker");
|
||||
@@ -119,19 +118,22 @@ fn main() {
|
||||
for (name, query, iterations) in test_queries {
|
||||
let start = Instant::now();
|
||||
let mut match_count = 0;
|
||||
let parser = QueryParser::default();
|
||||
|
||||
for _ in 0..iterations {
|
||||
let parsed = parser.parse(query);
|
||||
let results = FilePicker::fuzzy_search(
|
||||
&files,
|
||||
query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
parsed,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 4,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
@@ -1,4 +1,5 @@
|
||||
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::{FILE_PICKER, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::thread;
|
||||
@@ -196,20 +197,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let max_threads = 1 + (search_count % 8); // Vary thread count
|
||||
|
||||
let search_start = Instant::now();
|
||||
let parser = QueryParser::default();
|
||||
let (result_count, search_duration) = {
|
||||
let file_picker_guard = FILE_PICKER.read().unwrap();
|
||||
if let Some(ref picker) = *file_picker_guard {
|
||||
let parsed = parser.parse(query);
|
||||
let search_result = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
query,
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
parsed,
|
||||
FuzzySearchOptions {
|
||||
max_threads,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: max_results,
|
||||
},
|
||||
@@ -2,7 +2,9 @@
|
||||
#![allow(dead_code)]
|
||||
#![allow(clippy::enum_variant_names)]
|
||||
|
||||
use fff_nvim::{FILE_PICKER, FRECENCY, file_picker::FilePicker, git::format_git_status};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FILE_PICKER, FRECENCY, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use std::env;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::Arc;
|
||||
@@ -15,7 +17,7 @@ fn cleanup_global_state() {
|
||||
{
|
||||
let mut file_picker = FILE_PICKER.write().unwrap();
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
let _ = picker.stop_background_monitor();
|
||||
picker.stop_background_monitor();
|
||||
drop(picker);
|
||||
println!("🧹 FilePicker cleaned up");
|
||||
}
|
||||
@@ -156,17 +158,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let timestamp = chrono::Local::now().format("%H:%M:%S");
|
||||
let file_picker = FILE_PICKER.read().unwrap();
|
||||
let files = file_picker.as_ref().unwrap().get_files();
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse("rs");
|
||||
let search_results = FilePicker::fuzzy_search(
|
||||
files,
|
||||
"rs",
|
||||
fff_nvim::file_picker::FuzzySearchOptions {
|
||||
parsed,
|
||||
FuzzySearchOptions {
|
||||
max_threads: 2,
|
||||
current_file: None,
|
||||
project_path: None,
|
||||
last_same_query_match: None,
|
||||
combo_boost_score_multiplier: 100,
|
||||
min_combo_count: 3,
|
||||
pagination: fff_nvim::types::PaginationArgs {
|
||||
pagination: PaginationArgs {
|
||||
offset: 0,
|
||||
limit: 5,
|
||||
},
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Error handling for fff-nvim
|
||||
//!
|
||||
//! This module provides utilities for converting fff_core errors to mlua errors.
|
||||
|
||||
use fff_core::Error as CoreError;
|
||||
|
||||
/// Convert a fff_core::Error to mlua::Error
|
||||
///
|
||||
/// This function is used because we can't implement From<CoreError> for mlua::Error
|
||||
/// due to Rust's orphan rules (both types are foreign to this crate).
|
||||
pub fn to_lua_error(err: CoreError) -> mlua::Error {
|
||||
let string_value = err.to_string();
|
||||
::tracing::error!(string_value);
|
||||
mlua::Error::RuntimeError(string_value)
|
||||
}
|
||||
|
||||
/// Extension trait for Result<T, fff_core::Error> to convert to LuaResult<T>
|
||||
pub trait IntoLuaResult<T> {
|
||||
fn into_lua_result(self) -> mlua::Result<T>;
|
||||
}
|
||||
|
||||
impl<T> IntoLuaResult<T> for Result<T, CoreError> {
|
||||
fn into_lua_result(self) -> mlua::Result<T> {
|
||||
self.map_err(to_lua_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for Result<T, PoisonError> to convert to Result<T, CoreError>
|
||||
pub trait IntoCoreError<T> {
|
||||
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError>;
|
||||
}
|
||||
|
||||
impl<T, G> IntoCoreError<T> for Result<T, std::sync::PoisonError<G>> {
|
||||
fn with_lock_error(self, err: CoreError) -> Result<T, CoreError> {
|
||||
self.map_err(|_| err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
use crate::path_shortening::shorten_path_with_cache;
|
||||
use error::{IntoCoreError, IntoLuaResult};
|
||||
use fff_core::file_picker::FilePicker;
|
||||
use fff_core::frecency::FrecencyTracker;
|
||||
use fff_core::query_tracker::QueryTracker;
|
||||
use fff_core::{DbHealthChecker, Error, FuzzySearchOptions, PaginationArgs, QueryParser};
|
||||
use fff_core::{FILE_PICKER, FRECENCY, QUERY_TRACKER};
|
||||
use mimalloc::MiMalloc;
|
||||
use mlua::prelude::*;
|
||||
use path_shortening::PathShortenStrategy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
mod error;
|
||||
mod log;
|
||||
mod lua_types;
|
||||
mod path_shortening;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
pub fn init_db(
|
||||
_: &Lua,
|
||||
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
|
||||
) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
if frecency.is_some() {
|
||||
*frecency = None;
|
||||
}
|
||||
*frecency =
|
||||
Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock).into_lua_result()?);
|
||||
tracing::info!("Frecency database initialized at {}", frecency_db_path);
|
||||
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
if query_tracker.is_some() {
|
||||
*query_tracker = None;
|
||||
}
|
||||
|
||||
let tracker = QueryTracker::new(&history_db_path, use_unsafe_no_lock).into_lua_result()?;
|
||||
*query_tracker = Some(tracker);
|
||||
tracing::info!("Query tracker database initialized at {}", history_db_path);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn destroy_frecency_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
*frecency = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
*query_tracker = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
if file_picker.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let picker = FilePicker::new(base_path).into_lua_result()?;
|
||||
*file_picker = Some(picker);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)?;
|
||||
|
||||
// drop should clean it anyway but just to be extra sure
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
|
||||
*file_picker = Some(new_picker);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
let path = std::path::PathBuf::from(&new_path);
|
||||
if !path.exists() {
|
||||
return Err(LuaError::RuntimeError(format!(
|
||||
"Path does not exist: {}",
|
||||
new_path
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_path = fff_core::path_utils::canonicalize(&path).map_err(|e| {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
// Spawn a background thread to avoid blocking Lua/UI thread
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
|
||||
::tracing::error!(
|
||||
?e,
|
||||
?canonical_path,
|
||||
"Failed to index directory after changing"
|
||||
);
|
||||
} else {
|
||||
::tracing::info!(?canonical_path, "Successfully reindexed directory");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_mut()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
|
||||
picker.trigger_rescan().into_lua_result()?;
|
||||
::tracing::info!("scan_files trigger_rescan completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn fuzzy_search_files(
|
||||
lua: &Lua,
|
||||
(
|
||||
query,
|
||||
max_threads,
|
||||
current_file,
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
page_index,
|
||||
page_size,
|
||||
): (
|
||||
String,
|
||||
usize,
|
||||
Option<String>,
|
||||
i32,
|
||||
Option<u32>,
|
||||
Option<usize>,
|
||||
Option<usize>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let file_picker_guard = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker_guard else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
let base_path = picker.base_path();
|
||||
let min_combo_count = min_combo_count.unwrap_or(3);
|
||||
|
||||
let last_same_query_entry = {
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
|
||||
if query_tracker.as_ref().is_none() {
|
||||
tracing::warn!("Query tracker not initialized");
|
||||
}
|
||||
|
||||
query_tracker
|
||||
.as_ref()
|
||||
.map(|tracker| tracker.get_last_query_entry(&query, base_path, min_combo_count))
|
||||
.transpose()
|
||||
.into_lua_result()?
|
||||
.flatten()
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
?last_same_query_entry,
|
||||
?base_path,
|
||||
?query,
|
||||
?min_combo_count,
|
||||
?page_index,
|
||||
?page_size,
|
||||
"Fuzzy search parameters"
|
||||
);
|
||||
|
||||
// Parse the query once at the API boundary
|
||||
let parser = QueryParser::default();
|
||||
let parsed = parser.parse(&query);
|
||||
|
||||
let results = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
&query,
|
||||
parsed,
|
||||
FuzzySearchOptions {
|
||||
max_threads,
|
||||
current_file: current_file.as_deref(),
|
||||
project_path: Some(picker.base_path()),
|
||||
last_same_query_match: last_same_query_entry.as_ref(),
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
pagination: PaginationArgs {
|
||||
offset: page_index.unwrap_or(0),
|
||||
limit: page_size.unwrap_or(0),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
lua_types::SearchResultLua::from(results).into_lua(lua)
|
||||
}
|
||||
|
||||
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let file_path = PathBuf::from(&file_path);
|
||||
|
||||
// Track access in frecency DB (expensive LMDB write, ~100-200ms)
|
||||
// Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
frecency
|
||||
.track_access(file_path.as_path())
|
||||
.into_lua_result()?;
|
||||
drop(frecency_guard);
|
||||
|
||||
// Quick lock to update single file's frecency score in picker
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker
|
||||
.update_single_file_frecency(&file_path, frecency)
|
||||
.into_lua_result()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
let progress = picker.get_scan_progress();
|
||||
|
||||
let table = lua.create_table()?;
|
||||
table.set("scanned_files_count", progress.scanned_files_count)?;
|
||||
table.set("is_scanning", progress.is_scanning)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
Ok(picker.is_scan_active())
|
||||
}
|
||||
|
||||
pub fn get_git_root(_: &Lua, _: ()) -> LuaResult<Option<String>> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(picker.git_root().map(|p| p.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
|
||||
FilePicker::refresh_git_status_global().into_lua_result()
|
||||
}
|
||||
|
||||
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let frecency_guard = FRECENCY
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref frecency) = *frecency_guard else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
picker
|
||||
.update_single_file_frecency(&file_path, frecency)
|
||||
.into_lua_result()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref mut picker) = *file_picker else {
|
||||
return Err(error::to_lua_error(Error::FilePickerMissing));
|
||||
};
|
||||
|
||||
picker.stop_background_monitor();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
if let Some(picker) = file_picker.take() {
|
||||
drop(picker);
|
||||
::tracing::info!("FilePicker cleanup completed");
|
||||
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
|
||||
// Get the project path before spawning thread
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
// Canonicalize the file path before spawning thread
|
||||
let file_path = match fff_core::path_utils::canonicalize(&file_path) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn background thread to do the actual tracking (expensive DB write)
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
|
||||
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
|
||||
{
|
||||
tracing::error!(
|
||||
query = %query,
|
||||
file = %file_path.display(),
|
||||
error = ?e,
|
||||
"Failed to track query completion"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref picker) = *file_picker else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireFrecencyLock)
|
||||
.into_lua_result()?;
|
||||
let Some(ref tracker) = *query_tracker else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tracker
|
||||
.get_historical_query(&project_path, offset)
|
||||
.into_lua_result()
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER
|
||||
.read()
|
||||
.with_lock_error(Error::AcquireItemLock)
|
||||
.into_lua_result()?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or(Error::FilePickerMissing)
|
||||
.into_lua_result()?;
|
||||
|
||||
let timeout_ms = timeout_ms.unwrap_or(500);
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while picker.is_scan_active() {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
std::thread::sleep(sleep_duration);
|
||||
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
|
||||
}
|
||||
|
||||
::tracing::debug!(
|
||||
"wait_for_initial_scan completed in {:?}",
|
||||
start_time.elapsed()
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn init_tracing(
|
||||
_: &Lua,
|
||||
(log_file_path, log_level): (String, Option<String>),
|
||||
) -> LuaResult<String> {
|
||||
crate::log::init_tracing(&log_file_path, log_level.as_deref())
|
||||
.map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e)))
|
||||
}
|
||||
|
||||
/// Returns health check information including version, git2 status, and repository detection
|
||||
pub fn health_check(lua: &Lua, test_path: Option<String>) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("version", env!("CARGO_PKG_VERSION"))?;
|
||||
|
||||
let test_path = test_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||
|
||||
let git_info = lua.create_table()?;
|
||||
let git_version = git2::Version::get();
|
||||
let (major, minor, rev) = git_version.libgit2_version();
|
||||
let libgit2_version_str = format!("{}.{}.{}", major, minor, rev);
|
||||
|
||||
match git2::Repository::discover(&test_path) {
|
||||
Ok(repo) => {
|
||||
git_info.set("available", true)?;
|
||||
git_info.set("repository_found", true)?;
|
||||
if let Some(workdir) = repo.workdir() {
|
||||
git_info.set("workdir", workdir.to_string_lossy().to_string())?;
|
||||
}
|
||||
// Get git2 version info
|
||||
git_info.set("libgit2_version", libgit2_version_str.clone())?;
|
||||
}
|
||||
Err(e) => {
|
||||
git_info.set("available", true)?;
|
||||
git_info.set("repository_found", false)?;
|
||||
git_info.set("error", e.message().to_string())?;
|
||||
git_info.set("libgit2_version", libgit2_version_str)?;
|
||||
}
|
||||
}
|
||||
table.set("git", git_info)?;
|
||||
|
||||
// Check file picker status
|
||||
let picker_info = lua.create_table()?;
|
||||
match FILE_PICKER.read() {
|
||||
Ok(guard) => {
|
||||
if let Some(ref picker) = *guard {
|
||||
picker_info.set("initialized", true)?;
|
||||
picker_info.set(
|
||||
"base_path",
|
||||
picker.base_path().to_string_lossy().to_string(),
|
||||
)?;
|
||||
picker_info.set("is_scanning", picker.is_scan_active())?;
|
||||
let progress = picker.get_scan_progress();
|
||||
picker_info.set("indexed_files", progress.scanned_files_count)?;
|
||||
} else {
|
||||
picker_info.set("initialized", false)?;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
picker_info.set("initialized", false)?;
|
||||
picker_info.set("error", "Failed to acquire file picker lock")?;
|
||||
}
|
||||
}
|
||||
table.set("file_picker", picker_info)?;
|
||||
|
||||
let frecency_info = lua.create_table()?;
|
||||
match FRECENCY.read() {
|
||||
Ok(guard) => {
|
||||
frecency_info.set("initialized", guard.is_some())?;
|
||||
|
||||
if let Some(ref frecency) = *guard {
|
||||
match frecency.get_health() {
|
||||
Ok(health) => {
|
||||
let healthcheck_table = lua.create_table()?;
|
||||
healthcheck_table.set("path", health.path)?;
|
||||
healthcheck_table.set("disk_size", health.disk_size)?;
|
||||
for (name, count) in health.entry_counts {
|
||||
healthcheck_table.set(name, count)?;
|
||||
}
|
||||
frecency_info.set("db_healthcheck", healthcheck_table)?;
|
||||
}
|
||||
Err(e) => {
|
||||
frecency_info.set("db_healthcheck_error", e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
frecency_info.set("initialized", false)?;
|
||||
frecency_info.set("error", "Failed to acquire frecency lock")?;
|
||||
}
|
||||
}
|
||||
table.set("frecency", frecency_info)?;
|
||||
|
||||
let query_tracker_info = lua.create_table()?;
|
||||
match QUERY_TRACKER.read() {
|
||||
Ok(guard) => {
|
||||
query_tracker_info.set("initialized", guard.is_some())?;
|
||||
if let Some(ref query_history) = *guard {
|
||||
match query_history.get_health() {
|
||||
Ok(health) => {
|
||||
let healthcheck_table = lua.create_table()?;
|
||||
healthcheck_table.set("path", health.path)?;
|
||||
healthcheck_table.set("disk_size", health.disk_size)?;
|
||||
for (name, count) in health.entry_counts {
|
||||
healthcheck_table.set(name, count)?;
|
||||
}
|
||||
query_tracker_info.set("db_healthcheck", healthcheck_table)?;
|
||||
}
|
||||
Err(e) => {
|
||||
query_tracker_info.set("db_healthcheck_error", e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
query_tracker_info.set("initialized", false)?;
|
||||
query_tracker_info.set("error", "Failed to acquire query tracker lock")?;
|
||||
}
|
||||
}
|
||||
table.set("query_tracker", query_tracker_info)?;
|
||||
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
pub fn shorten_path(
|
||||
_: &Lua,
|
||||
(path, max_size, strategy): (String, usize, Option<mlua::Value>),
|
||||
) -> LuaResult<String> {
|
||||
let strategy = strategy
|
||||
.map(|v| -> LuaResult<PathShortenStrategy> {
|
||||
match v {
|
||||
mlua::Value::String(ref s) => {
|
||||
let name = s
|
||||
.to_str()
|
||||
.map(|s| s.to_owned())
|
||||
.unwrap_or_else(|_| "middle_number".to_string());
|
||||
Ok(PathShortenStrategy::from_name(&name))
|
||||
}
|
||||
_ => Ok(PathShortenStrategy::default()),
|
||||
}
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
shorten_path_with_cache(strategy, max_size, Path::new(&path)).map_err(LuaError::RuntimeError)
|
||||
}
|
||||
|
||||
fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
let exports = lua.create_table()?;
|
||||
exports.set("init_db", lua.create_function(init_db)?)?;
|
||||
exports.set(
|
||||
"destroy_frecency_db",
|
||||
lua.create_function(destroy_frecency_db)?,
|
||||
)?;
|
||||
exports.set("init_file_picker", lua.create_function(init_file_picker)?)?;
|
||||
exports.set(
|
||||
"restart_index_in_path",
|
||||
lua.create_function(restart_index_in_path)?,
|
||||
)?;
|
||||
exports.set("scan_files", lua.create_function(scan_files)?)?;
|
||||
exports.set(
|
||||
"fuzzy_search_files",
|
||||
lua.create_function(fuzzy_search_files)?,
|
||||
)?;
|
||||
exports.set("track_access", lua.create_function(track_access)?)?;
|
||||
exports.set("cancel_scan", lua.create_function(cancel_scan)?)?;
|
||||
exports.set("get_scan_progress", lua.create_function(get_scan_progress)?)?;
|
||||
exports.set(
|
||||
"refresh_git_status",
|
||||
lua.create_function(refresh_git_status)?,
|
||||
)?;
|
||||
exports.set("get_git_root", lua.create_function(get_git_root)?)?;
|
||||
exports.set(
|
||||
"stop_background_monitor",
|
||||
lua.create_function(stop_background_monitor)?,
|
||||
)?;
|
||||
exports.set("init_tracing", lua.create_function(init_tracing)?)?;
|
||||
exports.set(
|
||||
"wait_for_initial_scan",
|
||||
lua.create_function(wait_for_initial_scan)?,
|
||||
)?;
|
||||
exports.set(
|
||||
"cleanup_file_picker",
|
||||
lua.create_function(cleanup_file_picker)?,
|
||||
)?;
|
||||
exports.set("destroy_query_db", lua.create_function(destroy_query_db)?)?;
|
||||
exports.set(
|
||||
"track_query_completion",
|
||||
lua.create_function(track_query_completion)?,
|
||||
)?;
|
||||
exports.set(
|
||||
"get_historical_query",
|
||||
lua.create_function(get_historical_query)?,
|
||||
)?;
|
||||
exports.set("health_check", lua.create_function(health_check)?)?;
|
||||
exports.set("shorten_path", lua.create_function(shorten_path)?)?;
|
||||
|
||||
Ok(exports)
|
||||
}
|
||||
|
||||
// https://github.com/mlua-rs/mlua/issues/318
|
||||
#[mlua::lua_module(skip_memory_check)]
|
||||
fn fff_nvim(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
// Install panic hook IMMEDIATELY on module load
|
||||
// This ensures any panics are logged even if init_tracing is never called
|
||||
crate::log::install_panic_hook();
|
||||
|
||||
create_exports(lua)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::error::Error;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
@@ -86,8 +86,8 @@ pub fn install_panic_hook() {
|
||||
/// * `log_level` - Log level (trace, debug, info, warn, error)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, Error> {
|
||||
/// * `Result<String, io::Error>` - Full path to the log file on success
|
||||
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, io::Error> {
|
||||
// Install panic hook first (does nothing if already installed)
|
||||
install_panic_hook();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Lua type conversions for fff-core types
|
||||
//!
|
||||
//! This module provides IntoLua implementations for core types.
|
||||
|
||||
use fff_core::git::format_git_status;
|
||||
use fff_core::{FileItem, Location, Score, SearchResult};
|
||||
use mlua::prelude::*;
|
||||
|
||||
/// Wrapper for SearchResult that implements IntoLua
|
||||
pub struct SearchResultLua<'a> {
|
||||
inner: SearchResult<'a>,
|
||||
}
|
||||
|
||||
impl<'a> From<SearchResult<'a>> for SearchResultLua<'a> {
|
||||
fn from(inner: SearchResult<'a>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
struct LuaPosition((i32, i32));
|
||||
|
||||
impl IntoLua for LuaPosition {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("line", self.0.0)?;
|
||||
table.set("col", self.0.1)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
|
||||
fn file_item_into_lua(item: &FileItem, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("path", item.path.to_string_lossy().to_string())?;
|
||||
table.set("relative_path", item.relative_path.clone())?;
|
||||
table.set("name", item.file_name.clone())?;
|
||||
table.set("size", item.size)?;
|
||||
table.set("modified", item.modified)?;
|
||||
table.set("access_frecency_score", item.access_frecency_score)?;
|
||||
table.set(
|
||||
"modification_frecency_score",
|
||||
item.modification_frecency_score,
|
||||
)?;
|
||||
table.set("total_frecency_score", item.total_frecency_score)?;
|
||||
table.set("git_status", format_git_status(item.git_status))?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
fn score_into_lua(score: &Score, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("total", score.total)?;
|
||||
table.set("base_score", score.base_score)?;
|
||||
table.set("filename_bonus", score.filename_bonus)?;
|
||||
table.set("special_filename_bonus", score.special_filename_bonus)?;
|
||||
table.set("frecency_boost", score.frecency_boost)?;
|
||||
table.set("distance_penalty", score.distance_penalty)?;
|
||||
table.set("current_file_penalty", score.current_file_penalty)?;
|
||||
table.set("combo_match_boost", score.combo_match_boost)?;
|
||||
table.set("match_type", score.match_type)?;
|
||||
table.set("exact_match", score.exact_match)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
impl IntoLua for SearchResultLua<'_> {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
|
||||
// Convert items
|
||||
let items_table = lua.create_table()?;
|
||||
for (i, item) in self.inner.items.iter().enumerate() {
|
||||
items_table.set(i + 1, file_item_into_lua(item, lua)?)?;
|
||||
}
|
||||
table.set("items", items_table)?;
|
||||
|
||||
// Convert scores
|
||||
let scores_table = lua.create_table()?;
|
||||
for (i, score) in self.inner.scores.iter().enumerate() {
|
||||
scores_table.set(i + 1, score_into_lua(score, lua)?)?;
|
||||
}
|
||||
table.set("scores", scores_table)?;
|
||||
|
||||
table.set("total_matched", self.inner.total_matched)?;
|
||||
table.set("total_files", self.inner.total_files)?;
|
||||
|
||||
if let Some(location) = &self.inner.location {
|
||||
let location_table = lua.create_table()?;
|
||||
|
||||
match location {
|
||||
Location::Line(line) => {
|
||||
location_table.set("line", *line)?;
|
||||
}
|
||||
Location::Position { line, col } => {
|
||||
location_table.set("line", *line)?;
|
||||
location_table.set("col", *col)?;
|
||||
}
|
||||
Location::Range { start, end } => {
|
||||
location_table.set("start", LuaPosition(*start))?;
|
||||
location_table.set("end", LuaPosition(*end))?;
|
||||
}
|
||||
}
|
||||
|
||||
table.set("location", location_table)?;
|
||||
}
|
||||
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
//! Path shortening utilities for display in Neovim UI
|
||||
//!
|
||||
//! This module provides functionality to shorten file paths for display
|
||||
//! in the picker UI with various strategies.
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::borrow::Cow;
|
||||
use std::path::{Component, MAIN_SEPARATOR, Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub enum PathShortenStrategy {
|
||||
#[default]
|
||||
MiddleNumber,
|
||||
Middle,
|
||||
End,
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
shortened: String,
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
struct PathCache {
|
||||
map: ahash::AHashMap<PathBuf, CacheEntry>,
|
||||
max_entries: usize,
|
||||
}
|
||||
|
||||
impl PathCache {
|
||||
fn new(max_entries: usize) -> Self {
|
||||
Self {
|
||||
map: ahash::AHashMap::with_capacity(max_entries),
|
||||
max_entries,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size))]
|
||||
fn get(&self, path: &Path, max_size: usize) -> Option<&str> {
|
||||
self.map.get(path).and_then(|entry| {
|
||||
// Only return cached value if max_size matches
|
||||
if entry.max_size == max_size {
|
||||
Some(entry.shortened.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn insert(&mut self, path: PathBuf, shortened: String, max_size: usize) {
|
||||
// Simple eviction: clear half the cache when full
|
||||
if self.map.len() >= self.max_entries {
|
||||
let keys_to_remove: Vec<_> = self
|
||||
.map
|
||||
.keys()
|
||||
.take(self.max_entries / 2)
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in keys_to_remove {
|
||||
self.map.remove(&key);
|
||||
}
|
||||
}
|
||||
self.map.insert(
|
||||
path,
|
||||
CacheEntry {
|
||||
shortened,
|
||||
max_size,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// this is the amount of PATHS not entries
|
||||
const DEFAULT_CACHE_SIZE: usize = 8192;
|
||||
|
||||
static PATH_SHORTEN_CACHE: Lazy<RwLock<PathCache>> =
|
||||
Lazy::new(|| RwLock::new(PathCache::new(DEFAULT_CACHE_SIZE)));
|
||||
|
||||
pub fn shorten_path_with_cache(
|
||||
strategy: PathShortenStrategy,
|
||||
max_size: usize,
|
||||
path: &Path,
|
||||
) -> Result<String, String> {
|
||||
{
|
||||
let cache = PATH_SHORTEN_CACHE
|
||||
.read()
|
||||
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
|
||||
if let Some(cached) = cache.get(path, max_size) {
|
||||
tracing::debug!("Cache hit for path '{}'", path.display());
|
||||
return Ok(cached.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let shortened = strategy.shorten_path(path, max_size);
|
||||
{
|
||||
let mut cache = PATH_SHORTEN_CACHE
|
||||
.write()
|
||||
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
|
||||
cache.insert(path.to_path_buf(), shortened.clone(), max_size);
|
||||
}
|
||||
|
||||
Ok(shortened)
|
||||
}
|
||||
|
||||
impl PathShortenStrategy {
|
||||
/// Parse a strategy from a string name
|
||||
pub fn from_name(name: &str) -> Self {
|
||||
match name {
|
||||
"middle_number" => PathShortenStrategy::MiddleNumber,
|
||||
"middle" => PathShortenStrategy::Middle,
|
||||
"end" => PathShortenStrategy::End,
|
||||
_ => PathShortenStrategy::MiddleNumber,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PathShortenStrategy {
|
||||
pub fn shorten_path(&self, path: &Path, max_size: usize) -> String {
|
||||
const MIN_SMART_SHORTEN_SIZE: usize = 8;
|
||||
|
||||
let sep = MAIN_SEPARATOR;
|
||||
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.len() <= max_size {
|
||||
return path_str.to_string();
|
||||
}
|
||||
|
||||
// If max_size is too small for smart shortening, just truncate
|
||||
if max_size < MIN_SMART_SHORTEN_SIZE {
|
||||
return Self::truncate_str(&path_str, max_size);
|
||||
}
|
||||
|
||||
let components: Vec<&str> = path
|
||||
.components()
|
||||
.filter_map(|c| match c {
|
||||
Component::Normal(s) => s.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if components.is_empty() {
|
||||
return path_str.to_string();
|
||||
}
|
||||
|
||||
// For single component, just truncate it
|
||||
if components.len() == 1 {
|
||||
return Self::truncate_str(components[0], max_size);
|
||||
}
|
||||
|
||||
match self {
|
||||
PathShortenStrategy::End => {
|
||||
// Simple truncation from the end
|
||||
let mut result = String::new();
|
||||
|
||||
for (i, component) in components.iter().enumerate() {
|
||||
let candidate = if i == 0 {
|
||||
component.to_string()
|
||||
} else {
|
||||
format!("{}{}{}", result, sep, component)
|
||||
};
|
||||
|
||||
if candidate.len() <= max_size {
|
||||
result = candidate;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If even the first component is too long, truncate it
|
||||
if result.is_empty() && !components.is_empty() {
|
||||
return components.first().map_or(String::new(), |component| {
|
||||
let mut component = component.to_string();
|
||||
|
||||
component.truncate(max_size);
|
||||
component
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
PathShortenStrategy::Middle | PathShortenStrategy::MiddleNumber => {
|
||||
let use_number = matches!(self, PathShortenStrategy::MiddleNumber);
|
||||
self.shorten_middle(&components, max_size, use_number, sep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rust doesn't have an ergonomic way to clone and truncate
|
||||
fn truncate_str(s: &str, max_len: usize) -> String {
|
||||
if max_len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let char_count = s.chars().count();
|
||||
if char_count <= max_len {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
// Just take the first max_len characters - no ".." suffix
|
||||
s.chars().take(max_len).collect()
|
||||
}
|
||||
|
||||
fn shorten_middle(
|
||||
&self,
|
||||
components: &[&str],
|
||||
max_size: usize,
|
||||
use_number: bool,
|
||||
sep: char,
|
||||
) -> String {
|
||||
let total = components.len();
|
||||
|
||||
// For 2 components, just show both or truncate to fit
|
||||
if total <= 2 {
|
||||
let joined = components.join(&sep.to_string());
|
||||
if joined.len() <= max_size {
|
||||
return joined;
|
||||
}
|
||||
// Try to keep last intact, truncate first
|
||||
let last = components[total - 1];
|
||||
let available_for_first = max_size.saturating_sub(1 + last.len()); // sep + last
|
||||
if available_for_first > 0 && last.len() < max_size {
|
||||
let truncated = Self::truncate_str(components[0], available_for_first);
|
||||
let mut result = String::with_capacity(truncated.len() + 1 + last.len());
|
||||
result.push_str(&truncated);
|
||||
result.push(sep);
|
||||
result.push_str(last);
|
||||
return result;
|
||||
}
|
||||
// Last component alone exceeds max_size, must truncate it
|
||||
return Self::truncate_str(last, max_size);
|
||||
}
|
||||
|
||||
let first = components[0];
|
||||
let last = components[total - 1];
|
||||
|
||||
let initial_hidden = total - 2;
|
||||
let ellipsis = Self::make_ellipsis(initial_hidden, use_number);
|
||||
|
||||
// Minimum pattern: first/.../last
|
||||
let min_overhead = 2 + ellipsis.len(); // two separators + ellipsis
|
||||
let min_content = first.len() + last.len();
|
||||
|
||||
if min_content + min_overhead <= max_size {
|
||||
// We can fit first/.../last, now try to add more components
|
||||
return self.expand_middle(components, max_size, use_number, sep);
|
||||
}
|
||||
|
||||
// Need to truncate to fit max_size
|
||||
// Priority: keep last intact if possible, truncate first, then truncate last if needed
|
||||
let needed_for_last = last.len() + 1 + ellipsis.len() + 1; // sep + ellipsis + sep + last
|
||||
if needed_for_last <= max_size {
|
||||
let available_for_first = max_size - needed_for_last;
|
||||
let truncated_first = Self::truncate_str(first, available_for_first);
|
||||
let ellipsis = Self::make_ellipsis(initial_hidden, use_number);
|
||||
// truncated_first + sep + ellipsis + sep + last
|
||||
let capacity = truncated_first.len() + 1 + ellipsis.len() + 1 + last.len();
|
||||
let mut result = String::with_capacity(capacity);
|
||||
result.push_str(&truncated_first);
|
||||
result.push(sep);
|
||||
result.push_str(&ellipsis);
|
||||
result.push(sep);
|
||||
result.push_str(last);
|
||||
return result;
|
||||
}
|
||||
|
||||
let needed_for_ellipsis_last = ellipsis.len() + 1 + last.len(); // ellipsis + sep + last
|
||||
if needed_for_ellipsis_last <= max_size {
|
||||
let mut result = String::with_capacity(needed_for_ellipsis_last);
|
||||
result.push_str(&ellipsis);
|
||||
result.push(sep);
|
||||
result.push_str(last);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Can't fit ellipsis + last, just show as much of last as possible
|
||||
Self::truncate_str(last, max_size)
|
||||
}
|
||||
|
||||
fn expand_middle(
|
||||
&self,
|
||||
components: &[&str],
|
||||
max_size: usize,
|
||||
use_number: bool,
|
||||
sep: char,
|
||||
) -> String {
|
||||
let total = components.len();
|
||||
|
||||
// Start with minimum: first/...or..N../last
|
||||
let mut left_end = 1; // exclusive index for left components
|
||||
let mut right_start = total - 1; // inclusive index for right components
|
||||
|
||||
// Try to add more components from both sides
|
||||
loop {
|
||||
if right_start <= left_end {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut added = false;
|
||||
|
||||
// Try adding from RIGHT first (to show more context near the file)
|
||||
if right_start > left_end + 1 {
|
||||
let hidden = right_start - 1 - left_end;
|
||||
let candidate = Self::build_middle_result(
|
||||
components,
|
||||
left_end,
|
||||
right_start - 1,
|
||||
hidden,
|
||||
use_number,
|
||||
sep,
|
||||
);
|
||||
if candidate.len() <= max_size {
|
||||
right_start -= 1;
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try adding from LEFT
|
||||
if left_end < right_start - 1 {
|
||||
let hidden = right_start - (left_end + 1);
|
||||
let candidate = Self::build_middle_result(
|
||||
components,
|
||||
left_end + 1,
|
||||
right_start,
|
||||
hidden,
|
||||
use_number,
|
||||
sep,
|
||||
);
|
||||
if candidate.len() <= max_size {
|
||||
left_end += 1;
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !added {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let hidden = right_start - left_end;
|
||||
Self::build_middle_result(components, left_end, right_start, hidden, use_number, sep)
|
||||
}
|
||||
|
||||
fn build_middle_result(
|
||||
components: &[&str],
|
||||
left_end: usize,
|
||||
right_start: usize,
|
||||
hidden_count: usize,
|
||||
use_number: bool,
|
||||
sep: char,
|
||||
) -> String {
|
||||
let ellipsis = Self::make_ellipsis(hidden_count, use_number);
|
||||
|
||||
let left_parts = &components[..left_end];
|
||||
let right_parts = &components[right_start..];
|
||||
|
||||
// Pre-calculate capacity
|
||||
let left_len: usize = left_parts.iter().map(|s| s.len()).sum();
|
||||
let right_len: usize = right_parts.iter().map(|s| s.len()).sum();
|
||||
let left_seps = if left_parts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
left_parts.len() - 1
|
||||
};
|
||||
let right_seps = if right_parts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
right_parts.len() - 1
|
||||
};
|
||||
// +2 for separators around ellipsis (or +1 if left is empty)
|
||||
let extra_seps = if left_parts.is_empty() { 1 } else { 2 };
|
||||
let capacity = left_len + right_len + left_seps + right_seps + ellipsis.len() + extra_seps;
|
||||
|
||||
let mut result = String::with_capacity(capacity);
|
||||
|
||||
// Build left part
|
||||
for (i, part) in left_parts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
result.push(sep);
|
||||
}
|
||||
result.push_str(part);
|
||||
}
|
||||
|
||||
// Add separator before ellipsis (only if left is not empty)
|
||||
if !left_parts.is_empty() {
|
||||
result.push(sep);
|
||||
}
|
||||
|
||||
// Add ellipsis
|
||||
result.push_str(&ellipsis);
|
||||
|
||||
// Add separator after ellipsis
|
||||
result.push(sep);
|
||||
|
||||
// Build right part
|
||||
for (i, part) in right_parts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
result.push(sep);
|
||||
}
|
||||
result.push_str(part);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn make_ellipsis(hidden_count: usize, use_number: bool) -> Cow<'static, str> {
|
||||
match hidden_count {
|
||||
1 => ".".into(),
|
||||
2 => "..".into(),
|
||||
3 if use_number => "...".into(),
|
||||
n if use_number => format!(".{}.", n).into(),
|
||||
_ => "...".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_path_shorten_strategy_middle() {
|
||||
// Test with directory paths (not file paths) - this is what Lua passes
|
||||
let path = Path::new("core_workflow_service/db/model/parts/ai_extracted");
|
||||
|
||||
// With 25 chars, first component must be truncated
|
||||
// "core_workflow_service" is 21 chars, so we need to truncate it
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path, 25);
|
||||
assert!(
|
||||
shortened.len() <= 25,
|
||||
"Result '{}' should be <= 25 chars",
|
||||
shortened
|
||||
);
|
||||
assert!(shortened.contains("..."), "Should contain ellipsis");
|
||||
assert!(
|
||||
shortened.ends_with("ai_extracted"),
|
||||
"Should end with last component"
|
||||
);
|
||||
|
||||
// With 45 chars, can fit more without truncation
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path, 45);
|
||||
assert!(shortened.len() <= 45);
|
||||
assert!(shortened.starts_with("core_workflow_service"));
|
||||
|
||||
// Shorter path that fits better
|
||||
let path2 = Path::new("src/components/ui/buttons");
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path2, 20);
|
||||
assert!(
|
||||
shortened.len() <= 20,
|
||||
"Result '{}' should be <= 20 chars",
|
||||
shortened
|
||||
);
|
||||
|
||||
// Very small max_size - should still produce something reasonable
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path2, 10);
|
||||
assert!(
|
||||
shortened.len() <= 10,
|
||||
"Result '{}' should be <= 10 chars",
|
||||
shortened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_shroten_strategy_middle_number() {
|
||||
// Test with directory paths (not file paths)
|
||||
// middle_number uses dots for 1-3 hidden, numbers for 4+
|
||||
|
||||
// Path with only 2 hidden segments - should use dots
|
||||
let path = Path::new("core_workflow_service/graphql/types/parts");
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 40);
|
||||
assert!(
|
||||
shortened.len() <= 40,
|
||||
"Result '{}' should be <= 40 chars",
|
||||
shortened
|
||||
);
|
||||
// With only 2 hidden, should use dots not numbers
|
||||
assert!(
|
||||
shortened.contains('.'),
|
||||
"Should contain dots, got '{}'",
|
||||
shortened
|
||||
);
|
||||
|
||||
// Path with many segments, small space - should use .N. format when 4+ hidden
|
||||
let path2 = Path::new("a/b/c/d/e/f/g/h/i/j");
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path2, 12);
|
||||
assert!(
|
||||
shortened.len() <= 12,
|
||||
"Result '{}' should be <= 12 chars",
|
||||
shortened
|
||||
);
|
||||
// With 8 hidden (showing only a and j), should show number
|
||||
assert!(
|
||||
shortened.contains('.') && shortened.chars().any(|c| c.is_ascii_digit()),
|
||||
"Should contain .N. pattern for 4+ hidden, got '{}'",
|
||||
shortened
|
||||
);
|
||||
|
||||
// Very small max_size
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path2, 5);
|
||||
assert!(
|
||||
shortened.len() <= 5,
|
||||
"Result '{}' should be <= 5 chars",
|
||||
shortened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_shroten_strategy_end() {
|
||||
let path = Path::new("core_workflow_service/db/model/parts/ai_extracted");
|
||||
let shortened = PathShortenStrategy::End.shorten_path(path, 25);
|
||||
assert!(shortened.len() <= 25);
|
||||
assert!(shortened.starts_with("core_workflow_service"));
|
||||
|
||||
// Shorter constraint - truncates first component
|
||||
let shortened = PathShortenStrategy::End.shorten_path(path, 15);
|
||||
assert!(
|
||||
shortened.len() <= 15,
|
||||
"Result '{}' should be <= 15 chars",
|
||||
shortened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shorten_path_caching() {
|
||||
let path = Path::new("home/user/projects/rust/project/src/components/ui");
|
||||
|
||||
// First call should compute and cache
|
||||
let result1 = shorten_path_with_cache(PathShortenStrategy::MiddleNumber, 25, path).unwrap();
|
||||
|
||||
// Second call should hit cache
|
||||
let result2 = shorten_path_with_cache(PathShortenStrategy::MiddleNumber, 25, path).unwrap();
|
||||
|
||||
assert_eq!(result1, result2);
|
||||
|
||||
// Different max_size should produce different result (more space = longer result)
|
||||
let result3 = shorten_path_with_cache(PathShortenStrategy::MiddleNumber, 50, path).unwrap();
|
||||
assert!(
|
||||
result3.len() >= result1.len(),
|
||||
"More space should allow longer result"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_always_fits_max_size() {
|
||||
// Path must ALWAYS fit within max_size - this is a strict requirement
|
||||
let paths = [
|
||||
"core_workflow_service/db/model/parts/ai_extracted",
|
||||
"home/user/projects/rust/project/src",
|
||||
"a/b/c/d/e/f/g/h",
|
||||
"very_long_directory_name/another_long_one/and_more",
|
||||
];
|
||||
|
||||
for path_str in paths {
|
||||
let path = Path::new(path_str);
|
||||
for max_size in [10, 15, 20, 25, 30, 40, 50] {
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, max_size);
|
||||
assert!(
|
||||
shortened.len() <= max_size,
|
||||
"Path '{}' with max_size {} produced '{}' ({} chars)",
|
||||
path_str,
|
||||
max_size,
|
||||
shortened,
|
||||
shortened.len()
|
||||
);
|
||||
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path, max_size);
|
||||
assert!(
|
||||
shortened.len() <= max_size,
|
||||
"Path '{}' with max_size {} produced '{}' ({} chars)",
|
||||
path_str,
|
||||
max_size,
|
||||
shortened,
|
||||
shortened.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_max_size_simple_truncation() {
|
||||
// When max_size is very small (< MIN_SMART_SHORTEN_SIZE), should just truncate
|
||||
let path = Path::new("core_workflow_service/db/model/parts");
|
||||
|
||||
// With max_size=6, should just truncate (below threshold)
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 6);
|
||||
assert_eq!(shortened.len(), 6);
|
||||
assert_eq!(shortened, "core_w");
|
||||
|
||||
// With max_size=10, smart shortening kicks in
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path, 10);
|
||||
assert!(shortened.len() <= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prioritizes_last_component() {
|
||||
// When space allows, last component should be shown in full
|
||||
let path = Path::new("first/medium/last_component");
|
||||
|
||||
// With enough space, last component should be intact
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 25);
|
||||
assert!(
|
||||
shortened.ends_with("last_component"),
|
||||
"Should preserve last component when space allows, got '{}'",
|
||||
shortened
|
||||
);
|
||||
assert!(shortened.len() <= 25);
|
||||
|
||||
// When space is too tight, last component may be truncated to fit
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 10);
|
||||
assert!(
|
||||
shortened.len() <= 10,
|
||||
"Must fit within max_size, got '{}' ({} chars)",
|
||||
shortened,
|
||||
shortened.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "fff-query-parser"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
smallvec = { workspace = true }
|
||||
zlob = { version = "1.2.8" }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
[[bench]]
|
||||
name = "parse_bench"
|
||||
harness = false
|
||||
@@ -0,0 +1,180 @@
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main};
|
||||
use fff_query_parser::*;
|
||||
|
||||
fn bench_parse_simple(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_simple_text", |b| {
|
||||
b.iter(|| parser.parse(black_box("hello world")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_text_with_extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("name *.rs")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_complex(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
c.bench_function("parse_complex_mixed", |b| {
|
||||
b.iter(|| parser.parse(black_box("src name *.rs !test /lib/ status:modified")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_glob", |b| {
|
||||
b.iter(|| parser.parse(black_box("**/*.rs")));
|
||||
});
|
||||
|
||||
c.bench_function("parse_multiple_constraints", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs *.toml *.md !test !node_modules /src/")));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_parse_realistic_queries(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let queries = vec![
|
||||
"file",
|
||||
"test",
|
||||
"mod.rs",
|
||||
"src/*.rs",
|
||||
"lib test",
|
||||
"*.rs !test",
|
||||
"src/lib/*.rs",
|
||||
"/src/ name",
|
||||
"status:modified *.rs",
|
||||
"type:rust test !node_modules",
|
||||
];
|
||||
|
||||
let mut group = c.benchmark_group("realistic_queries");
|
||||
for query in queries.iter() {
|
||||
group.throughput(Throughput::Bytes(query.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::from_parameter(query), query, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_parse_various_lengths(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let short = "*.rs";
|
||||
let medium = "src name *.rs !test";
|
||||
let long = "src lib test name *.rs *.toml !node_modules !test /src/ /lib/ status:modified";
|
||||
let very_long =
|
||||
"a b c d e f g h i j k l m n o p q r s t u v w x y z *.rs *.toml *.md *.txt *.js";
|
||||
|
||||
let mut group = c.benchmark_group("query_lengths");
|
||||
|
||||
group.throughput(Throughput::Bytes(short.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("short", short.len()), &short, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(medium.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("medium", medium.len()), &medium, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(long.len() as u64));
|
||||
group.bench_with_input(BenchmarkId::new("long", long.len()), &long, |b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Bytes(very_long.len() as u64));
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("very_long", very_long.len()),
|
||||
&very_long,
|
||||
|b, q| {
|
||||
b.iter(|| parser.parse(black_box(q)));
|
||||
},
|
||||
);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_config_comparison(c: &mut Criterion) {
|
||||
let file_picker = QueryParser::new(FilePickerConfig);
|
||||
let grep = QueryParser::new(GrepConfig);
|
||||
|
||||
let query = "src name *.rs !test";
|
||||
|
||||
let mut group = c.benchmark_group("config_comparison");
|
||||
|
||||
group.bench_function("file_picker_config", |b| {
|
||||
b.iter(|| file_picker.parse(black_box(query)));
|
||||
});
|
||||
|
||||
group.bench_function("grep_config", |b| {
|
||||
b.iter(|| grep.parse(black_box(query)));
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_constraint_types(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
let mut group = c.benchmark_group("constraint_types");
|
||||
|
||||
group.bench_function("extension", |b| {
|
||||
b.iter(|| parser.parse(black_box("*.rs")));
|
||||
});
|
||||
|
||||
group.bench_function("glob", |b| {
|
||||
b.iter(|| parser.parse(black_box("**/*.rs")));
|
||||
});
|
||||
|
||||
group.bench_function("exclude", |b| {
|
||||
b.iter(|| parser.parse(black_box("!test")));
|
||||
});
|
||||
|
||||
group.bench_function("path_segment", |b| {
|
||||
b.iter(|| parser.parse(black_box("/src/")));
|
||||
});
|
||||
|
||||
group.bench_function("git_status", |b| {
|
||||
b.iter(|| parser.parse(black_box("status:modified")));
|
||||
});
|
||||
|
||||
group.bench_function("file_type", |b| {
|
||||
b.iter(|| parser.parse(black_box("type:rust")));
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_worst_case(c: &mut Criterion) {
|
||||
let parser = QueryParser::default();
|
||||
|
||||
// Worst case: many constraints that all need to be checked
|
||||
let worst_case = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
|
||||
|
||||
c.bench_function("worst_case_many_text_tokens", |b| {
|
||||
b.iter(|| parser.parse(black_box(worst_case)));
|
||||
});
|
||||
|
||||
// Many constraints
|
||||
let many_constraints = "*.rs *.toml *.md *.txt *.js *.ts *.jsx *.tsx *.vue *.svelte";
|
||||
|
||||
c.bench_function("worst_case_many_constraints", |b| {
|
||||
b.iter(|| parser.parse(black_box(many_constraints)));
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_parse_simple,
|
||||
bench_parse_complex,
|
||||
bench_parse_realistic_queries,
|
||||
bench_parse_various_lengths,
|
||||
bench_config_comparison,
|
||||
bench_constraint_types,
|
||||
bench_worst_case,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::constraints::Constraint;
|
||||
|
||||
/// Parser configuration trait - allows different picker types to customize parsing
|
||||
pub trait ParserConfig {
|
||||
fn enable_glob(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Should parse extension shortcuts (e.g., *.rs)
|
||||
fn enable_extension(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Should parse exclusion patterns (e.g., !test)
|
||||
fn enable_exclude(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Should parse path segments (e.g., /src/)
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Should parse type constraints (e.g., type:rust)
|
||||
fn enable_type_filter(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Should parse git status (e.g., status:modified)
|
||||
fn enable_git_status(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Custom constraint parsers for picker-specific needs
|
||||
fn parse_custom<'a>(&self, _input: &'a str) -> Option<Constraint<'a>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Default configuration for file picker - all features enabled
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct FilePickerConfig;
|
||||
|
||||
impl ParserConfig for FilePickerConfig {
|
||||
// All defaults enabled
|
||||
}
|
||||
|
||||
/// Configuration for full-text search (grep) - limited constraints
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct GrepConfig;
|
||||
|
||||
impl ParserConfig for GrepConfig {
|
||||
fn enable_extension(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_glob(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn enable_path_segments(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn enable_git_status(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// Constraint types that can be extracted from a query
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Constraint<'a> {
|
||||
/// Match file extension: *.rs -> Extension("rs")
|
||||
Extension(&'a str),
|
||||
|
||||
/// Glob pattern: **/*.rs -> Glob("**/*.rs")
|
||||
Glob(&'a str),
|
||||
|
||||
/// Multiple text search parts: ["src", "name"]
|
||||
/// Uses slice to avoid allocation
|
||||
Parts(&'a [&'a str]),
|
||||
|
||||
/// Single text token (optimized case)
|
||||
Text(&'a str),
|
||||
|
||||
/// Exclude pattern: !test -> Exclude(&["test"])
|
||||
Exclude(&'a [&'a str]),
|
||||
|
||||
/// Path constraint: /src/ -> PathSegment("src")
|
||||
PathSegment(&'a str),
|
||||
|
||||
/// File type constraint: type:rust -> FileType("rust")
|
||||
FileType(&'a str),
|
||||
|
||||
/// Git status constraint: status:modified -> GitStatus(Modified)
|
||||
GitStatus(GitStatusFilter),
|
||||
|
||||
/// Negation constraint: !extension:rs -> Not(Extension("rs"))
|
||||
/// Negates the inner constraint
|
||||
Not(Box<Constraint<'a>>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GitStatusFilter {
|
||||
Modified,
|
||||
Untracked,
|
||||
Staged,
|
||||
Unmodified,
|
||||
}
|
||||
|
||||
/// Stack-allocated buffer for text parts (up to 16 parts without heap allocation)
|
||||
pub(crate) type TextPartsBuffer<'a> = SmallVec<[&'a str; 16]>;
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Fast, zero-allocation query parser for file search
|
||||
//!
|
||||
//! This parser takes a search query and extracts structured constraints
|
||||
//! while preserving text for fuzzy matching. Designed for maximum performance:
|
||||
//! - Zero allocations for queries with ≤8 constraints (SmallVec)
|
||||
//! - Single-pass parsing with minimal branching
|
||||
//! - Stack-allocated string buffers
|
||||
//!
|
||||
//! # Examples
|
||||
//!
|
||||
//! ```
|
||||
//! use fff_query_parser::{QueryParser, Constraint, FuzzyQuery};
|
||||
//!
|
||||
//! let parser = QueryParser::default();
|
||||
//!
|
||||
//! // Single-token queries return None (no parsing needed)
|
||||
//! let result = parser.parse("hello");
|
||||
//! assert!(result.is_none());
|
||||
//!
|
||||
//! // Multi-token queries are parsed
|
||||
//! let result = parser.parse("name *.rs").expect("Should parse");
|
||||
//! match &result.fuzzy_query {
|
||||
//! FuzzyQuery::Text(text) => assert_eq!(*text, "name"),
|
||||
//! _ => panic!("Expected text"),
|
||||
//! }
|
||||
//! assert!(matches!(result.constraints[0], Constraint::Extension("rs")));
|
||||
//!
|
||||
//! // Parse glob pattern with text
|
||||
//! let result = parser.parse("**/*.rs foo").expect("Should parse");
|
||||
//! assert!(matches!(result.constraints[0], Constraint::Glob("**/*.rs")));
|
||||
//!
|
||||
//! // Parse negation
|
||||
//! let result = parser.parse("!*.rs foo").expect("Should parse");
|
||||
//! match &result.constraints[0] {
|
||||
//! Constraint::Not(inner) => {
|
||||
//! assert!(matches!(inner.as_ref(), Constraint::Extension("rs")));
|
||||
//! }
|
||||
//! _ => panic!("Expected Not constraint"),
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod config;
|
||||
mod constraints;
|
||||
pub mod location;
|
||||
mod parser;
|
||||
|
||||
pub use config::{FilePickerConfig, GrepConfig, ParserConfig};
|
||||
pub use constraints::{Constraint, GitStatusFilter};
|
||||
pub use location::Location;
|
||||
pub use parser::{FFFQuery, FuzzyQuery, QueryParser};
|
||||
|
||||
// Re-export SmallVec for convenience
|
||||
pub use smallvec::SmallVec;
|
||||
|
||||
/// Type alias for constraint vector - stack-allocated for ≤8 constraints
|
||||
pub type ConstraintVec<'a> = SmallVec<[Constraint<'a>; 8]>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_query() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser.parse("");
|
||||
// Empty query returns None (single-token behavior)
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitespace_only() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser.parse(" ");
|
||||
// Whitespace-only returns None
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_token() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser.parse("hello");
|
||||
// Single token returns None (no parsing needed)
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_text() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser
|
||||
.parse("hello world")
|
||||
.expect("Should parse multi-token");
|
||||
|
||||
match &result.fuzzy_query {
|
||||
FuzzyQuery::Parts(parts) => {
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert_eq!(parts[0], "hello");
|
||||
assert_eq!(parts[1], "world");
|
||||
}
|
||||
_ => panic!("Expected Parts fuzzy query"),
|
||||
}
|
||||
|
||||
assert_eq!(result.constraints.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_only() {
|
||||
let parser = QueryParser::default();
|
||||
// Single constraint token - returns Some so constraint can be applied
|
||||
let result = parser
|
||||
.parse("*.rs")
|
||||
.expect("Should parse single constraint");
|
||||
assert!(matches!(result.fuzzy_query, FuzzyQuery::Empty));
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(result.constraints[0], Constraint::Extension("rs")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_glob_pattern() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser
|
||||
.parse("**/*.rs foo")
|
||||
.expect("Should parse multi-token");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
// Glob patterns with ** are treated as globs, not extensions
|
||||
match &result.constraints[0] {
|
||||
Constraint::Glob(pattern) => assert_eq!(*pattern, "**/*.rs"),
|
||||
other => panic!("Expected Glob constraint, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negation_pattern() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser.parse("!test foo").expect("Should parse multi-token");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
match &result.constraints[0] {
|
||||
Constraint::Not(inner) => {
|
||||
assert!(matches!(**inner, Constraint::Text("test")));
|
||||
}
|
||||
_ => panic!("Expected Not constraint"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_segment() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser.parse("/src/ foo").expect("Should parse multi-token");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::PathSegment("src")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_status() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser
|
||||
.parse("status:modified foo")
|
||||
.expect("Should parse multi-token");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::GitStatus(GitStatusFilter::Modified)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_type() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser
|
||||
.parse("type:rust foo")
|
||||
.expect("Should parse multi-token");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::FileType("rust")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_query() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser
|
||||
.parse("src name *.rs !test /lib/ status:modified")
|
||||
.expect("Should parse");
|
||||
|
||||
// Verify we have fuzzy text
|
||||
match &result.fuzzy_query {
|
||||
FuzzyQuery::Parts(parts) => {
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert_eq!(parts[0], "src");
|
||||
assert_eq!(parts[1], "name");
|
||||
}
|
||||
_ => panic!("Expected Parts fuzzy query"),
|
||||
}
|
||||
|
||||
// Should have multiple constraints
|
||||
assert!(result.constraints.len() >= 4);
|
||||
|
||||
// Verify specific constraints exist
|
||||
let has_extension = result
|
||||
.constraints
|
||||
.iter()
|
||||
.any(|c| matches!(c, Constraint::Extension("rs")));
|
||||
let has_not = result
|
||||
.constraints
|
||||
.iter()
|
||||
.any(|c| matches!(c, Constraint::Not(_)));
|
||||
let has_path = result
|
||||
.constraints
|
||||
.iter()
|
||||
.any(|c| matches!(c, Constraint::PathSegment("lib")));
|
||||
let has_git_status = result
|
||||
.constraints
|
||||
.iter()
|
||||
.any(|c| matches!(c, Constraint::GitStatus(_)));
|
||||
|
||||
assert!(has_extension, "Should have Extension constraint");
|
||||
assert!(has_not, "Should have Not constraint");
|
||||
assert!(has_path, "Should have PathSegment constraint");
|
||||
assert!(has_git_status, "Should have GitStatus constraint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_heap_allocation_for_small_queries() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser
|
||||
.parse("*.rs *.toml !test")
|
||||
.expect("Should parse multi-token");
|
||||
// SmallVec should not have spilled to heap
|
||||
assert!(!result.constraints.spilled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_many_fuzzy_parts() {
|
||||
let parser = QueryParser::default();
|
||||
let result = parser
|
||||
.parse("one two three four five six")
|
||||
.expect("Should parse");
|
||||
|
||||
match &result.fuzzy_query {
|
||||
FuzzyQuery::Parts(parts) => {
|
||||
assert_eq!(parts.len(), 6);
|
||||
assert_eq!(parts[0], "one");
|
||||
assert_eq!(parts[5], "six");
|
||||
}
|
||||
_ => panic!("Expected Parts fuzzy query"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
//! Location parsing for file:line:col patterns
|
||||
//!
|
||||
//! Parses various location formats like:
|
||||
//! - `file:12` - Line number
|
||||
//! - `file:12:4` - Line and column
|
||||
//! - `file:12-114` - Line range
|
||||
//! - `file:12:4-20` - Column range on same line
|
||||
//! - `file:12:4-14:20` - Position range
|
||||
//! - `file(12)` - Visual Studio style line
|
||||
//! - `file(12,4)` - Visual Studio style line and column
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
||||
pub enum Location {
|
||||
Line(i32),
|
||||
@@ -143,6 +154,22 @@ fn parse_vstudio_location(query: &str) -> Option<(&str, Location)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse location from the end of a query string.
|
||||
///
|
||||
/// Returns the query without the location suffix, and the parsed location if found.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use fff_query_parser::location::{parse_location, Location};
|
||||
///
|
||||
/// let (query, loc) = parse_location("file:12");
|
||||
/// assert_eq!(query, "file");
|
||||
/// assert_eq!(loc, Some(Location::Line(12)));
|
||||
///
|
||||
/// let (query, loc) = parse_location("search term");
|
||||
/// assert_eq!(query, "search term");
|
||||
/// assert_eq!(loc, None);
|
||||
/// ```
|
||||
pub fn parse_location(query: &str) -> (&str, Option<Location>) {
|
||||
// simply ignore the last semicolon even if there are no additional location info
|
||||
let query = query.trim_end_matches([':', '-', '(']);
|
||||
@@ -159,7 +186,7 @@ pub fn parse_location(query: &str) -> (&str, Option<Location>) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub use super::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_location_parsing() {
|
||||
@@ -0,0 +1,494 @@
|
||||
use crate::ConstraintVec;
|
||||
use crate::config::ParserConfig;
|
||||
use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
|
||||
use crate::location::{Location, parse_location};
|
||||
use zlob::{ZlobFlags, has_wildcards};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum FuzzyQuery<'a> {
|
||||
Parts(TextPartsBuffer<'a>),
|
||||
Text(&'a str),
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FFFQuery<'a> {
|
||||
/// Parsed constraints (stack-allocated for ≤8 constraints)
|
||||
pub constraints: ConstraintVec<'a>,
|
||||
pub fuzzy_query: FuzzyQuery<'a>,
|
||||
/// Parsed location (e.g., file:12:4 -> line 12, col 4)
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
/// Main query parser - zero-cost wrapper around configuration
|
||||
#[derive(Debug)]
|
||||
pub struct QueryParser<C: ParserConfig> {
|
||||
config: C,
|
||||
}
|
||||
|
||||
impl<C: ParserConfig> QueryParser<C> {
|
||||
pub fn new(config: C) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub fn parse<'a>(&self, query: &'a str) -> Option<FFFQuery<'a>> {
|
||||
let query: &'a str = query;
|
||||
let config: &C = &self.config;
|
||||
let mut constraints = ConstraintVec::new();
|
||||
let query = query.trim();
|
||||
|
||||
let whitespace_count = query.chars().filter(|c| c.is_whitespace()).count();
|
||||
|
||||
// Single token - check if it's a constraint or plain text
|
||||
if whitespace_count == 0 {
|
||||
// Try to parse as constraint first
|
||||
if let Some(constraint) = parse_token(query, config) {
|
||||
constraints.push(constraint);
|
||||
return Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query: FuzzyQuery::Empty,
|
||||
location: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Try to extract location from single token (e.g., "file:12")
|
||||
let (query_without_loc, location) = parse_location(query);
|
||||
if location.is_some() {
|
||||
return Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query: FuzzyQuery::Text(query_without_loc),
|
||||
location,
|
||||
});
|
||||
}
|
||||
|
||||
// Plain text single token - return None (caller handles as simple fuzzy match)
|
||||
return None;
|
||||
}
|
||||
|
||||
// Stack-allocated buffer for text parts (up to 16 parts)
|
||||
let mut text_parts = TextPartsBuffer::new();
|
||||
let tokens = query.split_whitespace();
|
||||
|
||||
for token in tokens {
|
||||
match parse_token(token, config) {
|
||||
Some(constraint) => {
|
||||
constraints.push(constraint);
|
||||
}
|
||||
None => {
|
||||
text_parts.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract location from the last fuzzy token
|
||||
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
|
||||
let location = if !text_parts.is_empty() {
|
||||
let last_idx = text_parts.len() - 1;
|
||||
let (without_loc, loc) = parse_location(text_parts[last_idx]);
|
||||
if loc.is_some() {
|
||||
// Update the last part to be without the location suffix
|
||||
text_parts[last_idx] = without_loc;
|
||||
loc
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let fuzzy_query = if text_parts.is_empty() {
|
||||
FuzzyQuery::Empty
|
||||
} else if text_parts.len() == 1 {
|
||||
// If the only remaining text is empty after location extraction, treat as Empty
|
||||
if text_parts[0].is_empty() {
|
||||
FuzzyQuery::Empty
|
||||
} else {
|
||||
FuzzyQuery::Text(text_parts[0])
|
||||
}
|
||||
} else {
|
||||
// Filter out empty parts that might result from location extraction
|
||||
if text_parts.iter().all(|p| p.is_empty()) {
|
||||
FuzzyQuery::Empty
|
||||
} else {
|
||||
FuzzyQuery::Parts(text_parts)
|
||||
}
|
||||
};
|
||||
|
||||
Some(FFFQuery {
|
||||
constraints,
|
||||
fuzzy_query,
|
||||
location,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QueryParser<crate::FilePickerConfig> {
|
||||
fn default() -> Self {
|
||||
Self::new(crate::FilePickerConfig)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
|
||||
let first_byte = token.as_bytes().first()?;
|
||||
|
||||
match first_byte {
|
||||
b'*' if config.enable_extension() => {
|
||||
// Ignore incomplete patterns like "*" or "*."
|
||||
if token == "*" || token == "*." {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try extension first (*.rs) - simple patterns without additional wildcards
|
||||
if let Some(constraint) = parse_extension(token) {
|
||||
// Only return Extension if the rest doesn't have wildcards
|
||||
// e.g., *.rs is Extension, but *.test.* should be Glob
|
||||
let ext_part = &token[2..];
|
||||
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
// Has wildcards -> use zlob for matching
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
None
|
||||
}
|
||||
b'!' if config.enable_exclude() => parse_negation(token, config),
|
||||
b'/' if config.enable_path_segments() => parse_path_segment(token),
|
||||
_ if config.enable_path_segments() && token.ends_with('/') => {
|
||||
// Handle trailing slash syntax: www/ -> PathSegment("www")
|
||||
parse_path_segment_trailing(token)
|
||||
}
|
||||
_ => {
|
||||
// Check for glob patterns using zlob's SIMD-optimized detection
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
|
||||
// Check for key:value patterns
|
||||
if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
|
||||
let (key, value_with_colon) = token.split_at(colon_idx);
|
||||
let value = &value_with_colon[1..]; // Skip the colon
|
||||
|
||||
match key {
|
||||
"type" if config.enable_type_filter() => {
|
||||
return Some(Constraint::FileType(value));
|
||||
}
|
||||
"status" | "st" | "g" | "git" if config.enable_git_status() => {
|
||||
return parse_git_status(value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Try custom parsers
|
||||
config.parse_custom(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find first occurrence of byte in slice (fast memchr-like implementation)
|
||||
#[inline]
|
||||
fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
|
||||
haystack.iter().position(|&b| b == needle)
|
||||
}
|
||||
|
||||
/// Parse extension pattern: *.rs -> Extension("rs")
|
||||
#[inline]
|
||||
fn parse_extension(token: &str) -> Option<Constraint<'_>> {
|
||||
if token.len() > 2 && token.starts_with("*.") {
|
||||
Some(Constraint::Extension(&token[2..]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse negation pattern: !*.rs -> Not(Extension("rs")), !test -> Not(Text("test"))
|
||||
/// This allows negating any constraint type
|
||||
#[inline]
|
||||
fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
|
||||
if token.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let inner_token = &token[1..];
|
||||
|
||||
// Try to parse the inner token as any constraint
|
||||
if let Some(inner_constraint) = parse_token_without_negation(inner_token, config) {
|
||||
// Wrap it in a Not constraint
|
||||
return Some(Constraint::Not(Box::new(inner_constraint)));
|
||||
}
|
||||
|
||||
// If it's not a special constraint, treat it as negated text
|
||||
// For backward compatibility with !test syntax
|
||||
Some(Constraint::Not(Box::new(Constraint::Text(inner_token))))
|
||||
}
|
||||
|
||||
/// Parse a token without checking for negation (to avoid infinite recursion)
|
||||
#[inline]
|
||||
fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
token: &'a str,
|
||||
config: &C,
|
||||
) -> Option<Constraint<'a>> {
|
||||
let first_byte = token.as_bytes().first()?;
|
||||
|
||||
match first_byte {
|
||||
b'*' if config.enable_extension() => {
|
||||
// Try extension first (*.rs) - simple patterns without additional wildcards
|
||||
if let Some(constraint) = parse_extension(token) {
|
||||
let ext_part = &token[2..];
|
||||
if !has_wildcards(ext_part, ZlobFlags::RECOMMENDED) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
// Has wildcards -> use zlob for matching
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
None
|
||||
}
|
||||
b'/' if config.enable_path_segments() => parse_path_segment(token),
|
||||
_ if config.enable_path_segments() && token.ends_with('/') => {
|
||||
// Handle trailing slash syntax: www/ -> PathSegment("www")
|
||||
parse_path_segment_trailing(token)
|
||||
}
|
||||
_ => {
|
||||
// Check for glob patterns using zlob's SIMD-optimized detection
|
||||
if config.enable_glob() && has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
return Some(Constraint::Glob(token));
|
||||
}
|
||||
|
||||
// Check for key:value patterns
|
||||
if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
|
||||
let (key, value_with_colon) = token.split_at(colon_idx);
|
||||
let value = &value_with_colon[1..]; // Skip the colon
|
||||
|
||||
match key {
|
||||
"type" if config.enable_type_filter() => {
|
||||
return Some(Constraint::FileType(value));
|
||||
}
|
||||
"status" | "gi" | "g" | "st" if config.enable_git_status() => {
|
||||
return parse_git_status(value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
config.parse_custom(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse path segment: /src/ -> PathSegment("src")
|
||||
#[inline]
|
||||
fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
|
||||
if token.len() > 1 && token.starts_with('/') {
|
||||
let segment = token.trim_start_matches('/').trim_end_matches('/');
|
||||
if !segment.is_empty() {
|
||||
Some(Constraint::PathSegment(segment))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse path segment with trailing slash: www/ -> PathSegment("www")
|
||||
#[inline]
|
||||
fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
|
||||
if token.len() > 1 && token.ends_with('/') {
|
||||
let segment = token.trim_end_matches('/');
|
||||
if !segment.is_empty() && !segment.contains('/') {
|
||||
Some(Constraint::PathSegment(segment))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse git status filter: modified|m|untracked|u|staged|s
|
||||
#[inline]
|
||||
fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
|
||||
if value == "*" {
|
||||
return None;
|
||||
}
|
||||
|
||||
if "modified".starts_with(value) {
|
||||
return Some(Constraint::GitStatus(GitStatusFilter::Modified));
|
||||
}
|
||||
|
||||
if "untracked".starts_with(value) {
|
||||
return Some(Constraint::GitStatus(GitStatusFilter::Untracked));
|
||||
}
|
||||
|
||||
if "staged".starts_with(value) {
|
||||
return Some(Constraint::GitStatus(GitStatusFilter::Staged));
|
||||
}
|
||||
|
||||
if "clean".starts_with(value) {
|
||||
return Some(Constraint::GitStatus(GitStatusFilter::Unmodified));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::FilePickerConfig;
|
||||
|
||||
#[test]
|
||||
fn test_parse_extension() {
|
||||
assert_eq!(parse_extension("*.rs"), Some(Constraint::Extension("rs")));
|
||||
assert_eq!(
|
||||
parse_extension("*.toml"),
|
||||
Some(Constraint::Extension("toml"))
|
||||
);
|
||||
assert_eq!(parse_extension("*"), None);
|
||||
assert_eq!(parse_extension("*."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_incomplete_patterns_ignored() {
|
||||
let config = FilePickerConfig;
|
||||
// Incomplete patterns should return None and be treated as noise
|
||||
assert_eq!(parse_token("*", &config), None);
|
||||
assert_eq!(parse_token("*.", &config), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_path_segment() {
|
||||
assert_eq!(
|
||||
parse_path_segment("/src/"),
|
||||
Some(Constraint::PathSegment("src"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_path_segment("/lib"),
|
||||
Some(Constraint::PathSegment("lib"))
|
||||
);
|
||||
assert_eq!(parse_path_segment("/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_path_segment_trailing() {
|
||||
assert_eq!(
|
||||
parse_path_segment_trailing("www/"),
|
||||
Some(Constraint::PathSegment("www"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_path_segment_trailing("src/"),
|
||||
Some(Constraint::PathSegment("src"))
|
||||
);
|
||||
// Should not match paths with multiple segments
|
||||
assert_eq!(parse_path_segment_trailing("src/lib/"), None);
|
||||
// Should not match without trailing slash
|
||||
assert_eq!(parse_path_segment_trailing("www"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailing_slash_in_query() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("www/ test")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
assert!(matches!(
|
||||
result.constraints[0],
|
||||
Constraint::PathSegment("www")
|
||||
));
|
||||
assert!(matches!(result.fuzzy_query, FuzzyQuery::Text("test")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_status() {
|
||||
assert_eq!(
|
||||
parse_git_status("modified"),
|
||||
Some(Constraint::GitStatus(GitStatusFilter::Modified))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_git_status("m"),
|
||||
Some(Constraint::GitStatus(GitStatusFilter::Modified))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_git_status("untracked"),
|
||||
Some(Constraint::GitStatus(GitStatusFilter::Untracked))
|
||||
);
|
||||
assert_eq!(parse_git_status("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memchr() {
|
||||
assert_eq!(memchr(b':', b"type:rust"), Some(4));
|
||||
assert_eq!(memchr(b':', b"nocolon"), None);
|
||||
assert_eq!(memchr(b':', b":start"), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negation_text() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
// Need two tokens for parsing to return Some
|
||||
let result = parser
|
||||
.parse("!test foo")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
match &result.constraints[0] {
|
||||
Constraint::Not(inner) => {
|
||||
assert!(matches!(**inner, Constraint::Text("test")));
|
||||
}
|
||||
_ => panic!("Expected Not constraint"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negation_extension() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("!*.rs foo")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
match &result.constraints[0] {
|
||||
Constraint::Not(inner) => {
|
||||
assert!(matches!(**inner, Constraint::Extension("rs")));
|
||||
}
|
||||
_ => panic!("Expected Not(Extension) constraint"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negation_path_segment() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("!/src/ foo")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
match &result.constraints[0] {
|
||||
Constraint::Not(inner) => {
|
||||
assert!(matches!(**inner, Constraint::PathSegment("src")));
|
||||
}
|
||||
_ => panic!("Expected Not(PathSegment) constraint"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negation_git_status() {
|
||||
let parser = QueryParser::new(FilePickerConfig);
|
||||
let result = parser
|
||||
.parse("!status:modified foo")
|
||||
.expect("Should parse multi-token query");
|
||||
assert_eq!(result.constraints.len(), 1);
|
||||
match &result.constraints[0] {
|
||||
Constraint::Not(inner) => {
|
||||
assert!(matches!(
|
||||
**inner,
|
||||
Constraint::GitStatus(GitStatusFilter::Modified)
|
||||
));
|
||||
}
|
||||
_ => panic!("Expected Not(GitStatus) constraint"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 December 19
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 12
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
@@ -9,8 +9,7 @@ FFF.nvimFinally a smart fuzzy file picker for neovim.
|
||||
|
||||
|
||||
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
|
||||
opinionated fuzzy file picker for neovim. Just for files, but we’ll try to
|
||||
solve file picking completely.
|
||||
|
||||
@@ -132,6 +131,12 @@ all available options:
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -141,7 +146,6 @@ all available options:
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
wrap_lines = false,
|
||||
show_file_info = true,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
@@ -223,6 +227,7 @@ all available options:
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
@@ -347,6 +352,23 @@ with your own custom highlight groups to match your colorscheme.
|
||||
<
|
||||
|
||||
|
||||
FILE FILTERING
|
||||
|
||||
FFF.nvim respects `.gitignore` patterns automatically. To filter files from the
|
||||
picker without modifying `.gitignore`, create a `.ignore` file in your project
|
||||
root:
|
||||
|
||||
>gitignore
|
||||
# Exclude all markdown files
|
||||
*.md
|
||||
|
||||
# Exclude specific subdirectory
|
||||
docs/archive/**/*.md
|
||||
<
|
||||
|
||||
Run `:FFFScan` to force a rescan if needed.
|
||||
|
||||
|
||||
TROUBLESHOOTING ~
|
||||
|
||||
|
||||
|
||||
Generated
+9
-9
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1765739568,
|
||||
"narHash": "sha256-gQYx35Of4UDKUjAYvmxjUEh/DdszYeTtT6MDin4loGE=",
|
||||
"lastModified": 1767744144,
|
||||
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "67d2baff0f9f677af35db61b32b5df6863bcc075",
|
||||
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -35,11 +35,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1765644376,
|
||||
"narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=",
|
||||
"lastModified": 1767364772,
|
||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe",
|
||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -64,11 +64,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765680428,
|
||||
"narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=",
|
||||
"lastModified": 1770865833,
|
||||
"narHash": "sha256-oiARqnlvaW6pVGheVi4ye6voqCwhg5hCcGish2ZvQzI=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f",
|
||||
"rev": "c8cfbe26238638e2f3a2c0ae7e8d240f5e4ded85",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -41,11 +41,12 @@
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
strictDeps = true;
|
||||
|
||||
nativeBuildInputs = [ pkgs.pkg-config pkgs.perl ];
|
||||
nativeBuildInputs = [ pkgs.pkg-config pkgs.perl pkgs.zig pkgs.llvmPackages.libclang.lib ];
|
||||
buildInputs = with pkgs; [
|
||||
# Add additional build inputs here
|
||||
openssl
|
||||
];
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
};
|
||||
|
||||
my-crate = craneLib.buildPackage (
|
||||
|
||||
@@ -93,6 +93,7 @@ local function position_overlay_window(state_key, buf, width, row, col)
|
||||
row = row,
|
||||
col = col,
|
||||
style = 'minimal',
|
||||
border = 'none',
|
||||
focusable = false,
|
||||
zindex = 250,
|
||||
}
|
||||
@@ -106,15 +107,15 @@ local function position_overlay_window(state_key, buf, width, row, col)
|
||||
vim.api.nvim_win_set_option(overlay_state[state_key], 'winhl', 'Normal:Normal')
|
||||
end
|
||||
|
||||
local function update_overlays(list_win, combo_header_line, border_hl, prompt_position)
|
||||
local function update_overlays(list_win, combo_header_line, border_hl)
|
||||
local list_config = vim.api.nvim_win_get_config(list_win)
|
||||
-- combo_header_line is a 1-based buffer line index
|
||||
-- list_config.row is the window position (includes border)
|
||||
-- Buffer content starts at row + 1 (after top border)
|
||||
-- For bottom prompt: overlay needs adjustment due to different border handling
|
||||
-- For top prompt: use standard calculation
|
||||
-- combo_header_line is a 1-based buffer line index (includes any padding offset)
|
||||
-- list_config.row is where the window starts (0-based, at the top border)
|
||||
-- Content starts at list_config.row + 1 (after the top border)
|
||||
-- Buffer line 1 -> screen row (list_config.row + 1)
|
||||
-- Buffer line N -> screen row (list_config.row + N)
|
||||
-- Since combo_header_line is 1-based, the formula naturally works out
|
||||
local combo_header_row = list_config.row + combo_header_line
|
||||
if prompt_position == 'bottom' then combo_header_row = combo_header_row - 1 end
|
||||
|
||||
-- Skip update if position and highlight haven't changed
|
||||
if
|
||||
@@ -185,7 +186,8 @@ function M.render_highlights_and_overlays(
|
||||
ns_id,
|
||||
border_hl,
|
||||
item_to_lines,
|
||||
prompt_position
|
||||
prompt_position,
|
||||
total_items
|
||||
)
|
||||
local was_rendered_before = overlay_state.was_rendered
|
||||
local is_rendering_now = false
|
||||
@@ -199,7 +201,13 @@ function M.render_highlights_and_overlays(
|
||||
else
|
||||
local combo_header_line_idx = combo_item_lines.first
|
||||
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
|
||||
update_overlays(list_win, combo_header_line_idx, border_hl, prompt_position)
|
||||
if prompt_position == 'bottom' and total_items and total_items > 1 then
|
||||
combo_header_line_idx = combo_header_line_idx - 1
|
||||
end
|
||||
|
||||
-- when rendering items in the reverse order for some reason this makes the
|
||||
-- indexing shifted by one in the internal list config, so just adjust for that
|
||||
update_overlays(list_win, combo_header_line_idx, border_hl)
|
||||
is_rendering_now = true
|
||||
end
|
||||
end
|
||||
|
||||
+5
-1
@@ -114,6 +114,7 @@ local function init()
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
path_shorten_strategy = 'middle_number', -- or 'middle', 'end'
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -123,7 +124,6 @@ local function init()
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
wrap_lines = false,
|
||||
show_file_info = true,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
markdown = { wrap_lines = true },
|
||||
@@ -144,6 +144,8 @@ local function init()
|
||||
cycle_previous_query = '<C-Up>',
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
@@ -199,6 +201,7 @@ local function init()
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
@@ -227,6 +230,7 @@ end
|
||||
function M.toggle_debug()
|
||||
local old_debug_state = state.config.debug.show_scores
|
||||
state.config.debug.show_scores = not state.config.debug.show_scores
|
||||
state.config.debug.show_file_info = state.config.debug.show_scores
|
||||
local status = state.config.debug.show_scores and 'enabled' or 'disabled'
|
||||
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
|
||||
return old_debug_state ~= state.config.debug.show_scores
|
||||
|
||||
+7
-1
@@ -28,7 +28,13 @@ local function setup_global_autocmds(config)
|
||||
|
||||
vim.uv.fs_realpath(file_path, function(rp_err, real_path)
|
||||
if rp_err or not real_path then return end
|
||||
pcall(fuzzy.track_access, real_path)
|
||||
local ok, track_err = pcall(fuzzy.track_access, real_path)
|
||||
|
||||
if not ok then
|
||||
vim.schedule(
|
||||
function() vim.notify('FFF: Failed to track file access: ' .. tostring(track_err), vim.log.levels.ERROR) end
|
||||
)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
local M = {}
|
||||
local system = require('fff.utils.system')
|
||||
local uv = vim and vim.uv or require('luv')
|
||||
local fs_utils = require('fff.utils.fs')
|
||||
|
||||
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
|
||||
|
||||
@@ -24,7 +24,7 @@ end
|
||||
|
||||
local function binary_exists(plugin_dir)
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
local stat = uv.fs_stat(binary_path)
|
||||
local stat = vim.uv.fs_stat(binary_path)
|
||||
return stat and stat.type == 'file'
|
||||
end
|
||||
|
||||
@@ -32,9 +32,9 @@ local function download_file(url, output_path, opts, callback)
|
||||
opts = opts or {}
|
||||
|
||||
local dir = vim.fn.fnamemodify(output_path, ':h')
|
||||
uv.fs_mkdir(dir, 493, function(err) -- 493 = 0755 octal
|
||||
if err and not err:match('EEXIST') then
|
||||
callback(false, 'Failed to create directory: ' .. err)
|
||||
fs_utils.mkdir_recursive(dir, function(mkdir_ok, mkdir_err)
|
||||
if not mkdir_ok then
|
||||
callback(false, mkdir_err)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -89,7 +89,7 @@ local function download_from_github(version, binary_path, opts, callback)
|
||||
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
|
||||
|
||||
if not ok then
|
||||
uv.fs_unlink(binary_path)
|
||||
vim.uv.fs_unlink(binary_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -225,4 +225,41 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check image preview availability status
|
||||
--- @return table status { available: boolean, snacks_available: boolean, snacks_image_available: boolean, terminal_supported: boolean, error: string|nil }
|
||||
function M.get_preview_status()
|
||||
local status = {
|
||||
available = false,
|
||||
snacks_available = false,
|
||||
snacks_image_available = false,
|
||||
terminal_supported = false,
|
||||
error = nil,
|
||||
}
|
||||
|
||||
local ok, snacks = pcall(require, 'snacks')
|
||||
if not ok then
|
||||
status.error = 'snacks.nvim not installed'
|
||||
return status
|
||||
end
|
||||
|
||||
status.snacks_available = true
|
||||
|
||||
if not snacks.image then
|
||||
status.error = 'snacks.image module not available'
|
||||
return status
|
||||
end
|
||||
|
||||
status.snacks_image_available = true
|
||||
|
||||
if not snacks.image.supports_terminal or not snacks.image.supports_terminal() then
|
||||
status.error = 'terminal does not support image display'
|
||||
return status
|
||||
end
|
||||
|
||||
status.terminal_supported = true
|
||||
status.available = true
|
||||
|
||||
return status
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -83,8 +83,9 @@ function M.render_line(item, ctx, item_idx)
|
||||
end
|
||||
|
||||
-- Format filename and path
|
||||
-- Don't reserve space for frecency - path takes priority
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width - #frecency, 40)
|
||||
local available_width = math.max(ctx.max_path_width - icon_width, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- Build line
|
||||
|
||||
@@ -39,4 +39,11 @@ M.destroy_query_db = rust_module.destroy_query_db
|
||||
M.track_query_completion = rust_module.track_query_completion
|
||||
M.get_historical_query = rust_module.get_historical_query
|
||||
|
||||
-- Git functions
|
||||
M.get_git_root = rust_module.get_git_root
|
||||
|
||||
-- Utility functions
|
||||
M.health_check = rust_module.health_check
|
||||
M.shorten_path = rust_module.shorten_path
|
||||
|
||||
return M
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
local utils = require('fff.utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
local function fetch_rust_checkhealth(rust_module, test_path)
|
||||
if not rust_module.health_check then
|
||||
return nil, 'health_check function not available in rust module (binary may be outdated)'
|
||||
end
|
||||
|
||||
local ok, result = pcall(rust_module.health_check, test_path)
|
||||
if not ok then return nil, 'Failed to call health_check: ' .. tostring(result) end
|
||||
|
||||
return result, nil
|
||||
end
|
||||
|
||||
--- Check snacks.nvim image preview availability
|
||||
--- @return table image_preview_info
|
||||
local function check_image_preview()
|
||||
local ok, image = pcall(require, 'fff.file_picker.image')
|
||||
if not ok then
|
||||
return {
|
||||
available = false,
|
||||
snacks_available = false,
|
||||
snacks_image_available = false,
|
||||
terminal_supported = false,
|
||||
error = 'failed to load image module',
|
||||
}
|
||||
end
|
||||
|
||||
return image.get_preview_status()
|
||||
end
|
||||
|
||||
--- Check icon provider availability
|
||||
--- @return table icon_provider_info
|
||||
local function check_icon_provider()
|
||||
local ok, icons = pcall(require, 'fff.file_picker.icons')
|
||||
if not ok then return {
|
||||
available = false,
|
||||
name = nil,
|
||||
supports_directories = false,
|
||||
} end
|
||||
|
||||
return icons.get_provider_info()
|
||||
end
|
||||
|
||||
--- Run the health check and return structured results
|
||||
--- @param opts? { test_path?: string } Options for health check
|
||||
--- @return table health_result
|
||||
function M.run(opts)
|
||||
opts = opts or {}
|
||||
|
||||
local health = {
|
||||
ok = true,
|
||||
binary = {
|
||||
available = false,
|
||||
path = nil,
|
||||
error = nil,
|
||||
},
|
||||
rust = {
|
||||
version = nil,
|
||||
git = {
|
||||
available = false,
|
||||
repository_found = false,
|
||||
workdir = nil,
|
||||
libgit2_version = nil,
|
||||
error = nil,
|
||||
},
|
||||
file_picker = {
|
||||
initialized = false,
|
||||
base_path = nil,
|
||||
is_scanning = false,
|
||||
indexed_files = 0,
|
||||
error = nil,
|
||||
},
|
||||
frecency = {
|
||||
initialized = false,
|
||||
db_path = nil,
|
||||
disk_size = nil,
|
||||
entries = nil,
|
||||
error = nil,
|
||||
},
|
||||
query_tracker = {
|
||||
initialized = false,
|
||||
db_path = nil,
|
||||
disk_size = nil,
|
||||
query_file_entries = nil,
|
||||
query_history_entries = nil,
|
||||
error = nil,
|
||||
},
|
||||
},
|
||||
image_preview = {
|
||||
available = false,
|
||||
snacks_available = false,
|
||||
snacks_image_available = false,
|
||||
terminal_supported = false,
|
||||
error = nil,
|
||||
},
|
||||
icon_provider = {
|
||||
available = false,
|
||||
name = nil,
|
||||
supports_directories = false,
|
||||
},
|
||||
messages = {},
|
||||
}
|
||||
|
||||
-- Check binary availability
|
||||
local download = require('fff.download')
|
||||
health.binary.path = download.get_binary_path()
|
||||
|
||||
local binary_ok, rust_module = pcall(require, 'fff.rust')
|
||||
if not binary_ok then
|
||||
health.ok = false
|
||||
health.binary.available = false
|
||||
health.binary.error = tostring(rust_module)
|
||||
table.insert(health.messages, {
|
||||
level = 'error',
|
||||
msg = 'Binary not available: ' .. tostring(rust_module),
|
||||
})
|
||||
return health
|
||||
end
|
||||
|
||||
health.binary.available = true
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Binary loaded successfully from: ' .. health.binary.path,
|
||||
})
|
||||
|
||||
local rust_health, rust_err = fetch_rust_checkhealth(rust_module, opts.test_path)
|
||||
if rust_health then
|
||||
health.rust.version = rust_health.version
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'fff.nvim version: ' .. (rust_health.version or 'unknown'),
|
||||
})
|
||||
|
||||
if rust_health.git then
|
||||
health.rust.git.available = rust_health.git.available
|
||||
health.rust.git.repository_found = rust_health.git.repository_found
|
||||
health.rust.git.workdir = rust_health.git.workdir
|
||||
health.rust.git.libgit2_version = rust_health.git.libgit2_version
|
||||
health.rust.git.error = rust_health.git.error
|
||||
|
||||
if rust_health.git.available then
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'libgit2 available (version: ' .. (rust_health.git.libgit2_version or 'unknown') .. ')',
|
||||
})
|
||||
|
||||
if rust_health.git.repository_found then
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Git repository found: ' .. (rust_health.git.workdir or 'unknown'),
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'No git repository found in current directory'
|
||||
.. (rust_health.git.error and (': ' .. rust_health.git.error) or ''),
|
||||
})
|
||||
end
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'warn',
|
||||
msg = 'libgit2 not available',
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if rust_health.file_picker then
|
||||
health.rust.file_picker.initialized = rust_health.file_picker.initialized
|
||||
health.rust.file_picker.base_path = rust_health.file_picker.base_path
|
||||
health.rust.file_picker.is_scanning = rust_health.file_picker.is_scanning
|
||||
health.rust.file_picker.indexed_files = rust_health.file_picker.indexed_files
|
||||
health.rust.file_picker.error = rust_health.file_picker.error
|
||||
|
||||
if rust_health.file_picker.initialized then
|
||||
local status = rust_health.file_picker.is_scanning and 'scanning' or 'ready'
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = string.format(
|
||||
'File picker initialized (%s, %d files indexed, base: %s)',
|
||||
status,
|
||||
rust_health.file_picker.indexed_files or 0,
|
||||
rust_health.file_picker.base_path or 'unknown'
|
||||
),
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'File picker not initialized (will initialize on first use)',
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Frecency database status
|
||||
if rust_health.frecency then
|
||||
health.rust.frecency.initialized = rust_health.frecency.initialized
|
||||
health.rust.frecency.error = rust_health.frecency.error
|
||||
|
||||
if rust_health.frecency.initialized then
|
||||
local db_info = rust_health.frecency.db_healthcheck
|
||||
if db_info then
|
||||
health.rust.frecency.db_path = db_info.path
|
||||
health.rust.frecency.disk_size = db_info.disk_size
|
||||
health.rust.frecency.entries = db_info.absolute_frecency_entries
|
||||
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = string.format(
|
||||
'Frecency database initialized (%d entries, %s, path: %s)',
|
||||
db_info.absolute_frecency_entries or 0,
|
||||
utils.format_file_size(db_info.disk_size or 0),
|
||||
db_info.path or 'unknown'
|
||||
),
|
||||
})
|
||||
elseif rust_health.frecency.db_healthcheck_error then
|
||||
table.insert(health.messages, {
|
||||
level = 'warn',
|
||||
msg = 'Frecency database initialized but health check failed: '
|
||||
.. rust_health.frecency.db_healthcheck_error,
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Frecency database initialized',
|
||||
})
|
||||
end
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Frecency database not initialized (will initialize on first use)',
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if rust_health.query_tracker then
|
||||
health.rust.query_tracker.initialized = rust_health.query_tracker.initialized
|
||||
health.rust.query_tracker.error = rust_health.query_tracker.error
|
||||
|
||||
if rust_health.query_tracker.initialized then
|
||||
local db_info = rust_health.query_tracker.db_healthcheck
|
||||
if db_info then
|
||||
health.rust.query_tracker.db_path = db_info.path
|
||||
health.rust.query_tracker.disk_size = db_info.disk_size
|
||||
health.rust.query_tracker.query_file_entries = db_info.query_file_entries
|
||||
health.rust.query_tracker.query_history_entries = db_info.query_history_entries
|
||||
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = string.format(
|
||||
'Query tracker initialized (%d query-file mappings, %d history entries, %s, path: %s)',
|
||||
db_info.query_file_entries or 0,
|
||||
db_info.query_history_entries or 0,
|
||||
utils.format_file_size(db_info.disk_size or 0),
|
||||
db_info.path or 'unknown'
|
||||
),
|
||||
})
|
||||
elseif rust_health.query_tracker.db_healthcheck_error then
|
||||
table.insert(health.messages, {
|
||||
level = 'warn',
|
||||
msg = 'Query tracker initialized but health check failed: '
|
||||
.. rust_health.query_tracker.db_healthcheck_error,
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Query tracker initialized',
|
||||
})
|
||||
end
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Query tracker not initialized (will initialize on first use)',
|
||||
})
|
||||
end
|
||||
end
|
||||
else
|
||||
health.ok = false
|
||||
table.insert(health.messages, {
|
||||
level = 'error',
|
||||
msg = rust_err or 'Unknown error getting rust health data',
|
||||
})
|
||||
return health
|
||||
end
|
||||
|
||||
local image_info = check_image_preview()
|
||||
health.image_preview.snacks_available = image_info.snacks_available
|
||||
health.image_preview.snacks_image_available = image_info.snacks_image_available
|
||||
health.image_preview.terminal_supported = image_info.terminal_supported
|
||||
health.image_preview.error = image_info.error
|
||||
health.image_preview.available = image_info.available
|
||||
|
||||
if image_info.available then
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Image preview available via snacks.nvim',
|
||||
})
|
||||
elseif image_info.snacks_available and image_info.snacks_image_available then
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Image preview not available: ' .. (image_info.error or 'terminal does not support images'),
|
||||
})
|
||||
elseif image_info.snacks_available then
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Image preview not available: snacks.image module not found',
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Image preview not available: snacks.nvim not installed',
|
||||
})
|
||||
end
|
||||
|
||||
local icon_info = check_icon_provider()
|
||||
health.icon_provider.available = icon_info.available
|
||||
health.icon_provider.name = icon_info.name
|
||||
health.icon_provider.supports_directories = icon_info.supports_directories
|
||||
|
||||
if icon_info.available then
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Filetype icons available via ' .. icon_info.name,
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Filetype icons not available (install nvim-web-devicons or mini.icons)',
|
||||
})
|
||||
end
|
||||
|
||||
return health
|
||||
end
|
||||
|
||||
function M.check()
|
||||
vim.health.start('fff.nvim')
|
||||
|
||||
local result = M.run()
|
||||
|
||||
for _, msg in ipairs(result.messages) do
|
||||
if msg.level == 'ok' then
|
||||
vim.health.ok(msg.msg)
|
||||
elseif msg.level == 'warn' then
|
||||
vim.health.warn(msg.msg)
|
||||
elseif msg.level == 'error' then
|
||||
vim.health.error(msg.msg)
|
||||
elseif msg.level == 'info' then
|
||||
vim.health.info(msg.msg)
|
||||
end
|
||||
end
|
||||
|
||||
if not result.binary.available then
|
||||
vim.health.info('To install the binary, run:')
|
||||
vim.health.info(' :lua require("fff.download").download_or_build_binary()')
|
||||
vim.health.info('Or build from source with:')
|
||||
vim.health.info(' cargo build --release')
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
+16
-51
@@ -1,6 +1,3 @@
|
||||
-- PERF: By default, this plugin initializes itself lazily,
|
||||
-- so we do not require any modules at the top of this module.
|
||||
|
||||
local M = {}
|
||||
|
||||
M.state = { initialized = false }
|
||||
@@ -21,8 +18,10 @@ function M.find_files(opts)
|
||||
end
|
||||
|
||||
function M.find_in_git_root()
|
||||
local git_root = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null'):gsub('\n', '')
|
||||
if vim.v.shell_error ~= 0 then
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, git_root = pcall(fuzzy.get_git_root)
|
||||
|
||||
if not ok or not git_root then
|
||||
vim.notify('Not in a git repository', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
@@ -56,10 +55,20 @@ function M.search(query, max_results)
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local config = require('fff.conf').get()
|
||||
max_results = max_results or config.max_results
|
||||
local max_threads = config.max_threads or 4
|
||||
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
|
||||
local min_combo_count = config.history and config.history.min_combo_count or 3
|
||||
local ok, search_result =
|
||||
pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil, false, combo_boost_score_multiplier, min_combo_count)
|
||||
-- Args: query, max_threads, current_file, combo_boost_score_multiplier, min_combo_count, offset, page_size
|
||||
local ok, search_result = pcall(
|
||||
fuzzy.fuzzy_search_files,
|
||||
query,
|
||||
max_threads,
|
||||
nil,
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
0,
|
||||
max_results
|
||||
)
|
||||
if ok and search_result.items then return search_result.items end
|
||||
return {}
|
||||
end
|
||||
@@ -121,50 +130,6 @@ function M.get_preview(file_path)
|
||||
return table.concat(lines, '\n')
|
||||
end
|
||||
|
||||
function M.health_check()
|
||||
local health = {
|
||||
ok = true,
|
||||
messages = {},
|
||||
}
|
||||
|
||||
if not require('fff.core').is_file_picker_initialized() then
|
||||
health.ok = false
|
||||
table.insert(health.messages, 'File picker not initialized')
|
||||
else
|
||||
table.insert(health.messages, '✓ File picker initialized')
|
||||
end
|
||||
|
||||
local optional_deps = {
|
||||
{ cmd = 'git', desc = 'Git integration' },
|
||||
{ cmd = 'chafa', desc = 'Terminal graphics for image preview' },
|
||||
{ cmd = 'img2txt', desc = 'ASCII art for image preview' },
|
||||
{ cmd = 'viu', desc = 'Terminal images for image preview' },
|
||||
}
|
||||
|
||||
for _, dep in ipairs(optional_deps) do
|
||||
if vim.fn.executable(dep.cmd) == 0 then
|
||||
table.insert(health.messages, string.format('Optional: %s not found (%s)', dep.cmd, dep.desc))
|
||||
else
|
||||
table.insert(health.messages, string.format('✓ %s found', dep.cmd))
|
||||
end
|
||||
end
|
||||
|
||||
if health.ok then
|
||||
vim.notify('FFF health check passed ✓', vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('FFF health check failed ✗', vim.log.levels.ERROR)
|
||||
end
|
||||
|
||||
for _, message in ipairs(health.messages) do
|
||||
local level = message:match('^✓') and vim.log.levels.INFO
|
||||
or message:match('^Optional:') and vim.log.levels.WARN
|
||||
or vim.log.levels.ERROR
|
||||
vim.notify(message, level)
|
||||
end
|
||||
|
||||
return health
|
||||
end
|
||||
|
||||
--- Find files in a specific directory
|
||||
--- @param directory string Directory path to search in
|
||||
function M.find_files_in_dir(directory)
|
||||
|
||||
+209
-147
@@ -7,6 +7,37 @@ local utils = require('fff.utils')
|
||||
local location_utils = require('fff.location_utils')
|
||||
local combo_renderer = require('fff.combo_renderer')
|
||||
local scrollbar = require('fff.scrollbar')
|
||||
local rust = require('fff.rust')
|
||||
|
||||
local BORDER_PRESETS = {
|
||||
single = { '┌', '─', '┐', '│', '┘', '─', '└', '│' },
|
||||
double = { '╔', '═', '╗', '║', '╝', '═', '╚', '║' },
|
||||
rounded = { '╭', '─', '╮', '│', '╯', '─', '╰', '│' },
|
||||
solid = { '▛', '▀', '▜', '▐', '▟', '▄', '▙', '▌' },
|
||||
shadow = { '', '', ' ', ' ', ' ', ' ', ' ', '' },
|
||||
none = { '', '', '', '', '', '', '', '' },
|
||||
}
|
||||
|
||||
local T_JUNCTION_PRESETS = {
|
||||
single = { '├', '┤' },
|
||||
double = { '╠', '╣' },
|
||||
rounded = { '├', '┤' }, -- Rounded only affects corners
|
||||
solid = { '▌', '▐' },
|
||||
shadow = { '', '' },
|
||||
none = { '', '' },
|
||||
}
|
||||
|
||||
--- Get border characters from vim.o.winborder for custom connected borders
|
||||
--- @return table Array of 8 border characters
|
||||
--- @return table Array of 2 T-junction characters (left, right)
|
||||
local function get_border_chars()
|
||||
local winborder = vim.o.winborder or 'single'
|
||||
|
||||
if BORDER_PRESETS[winborder] then return BORDER_PRESETS[winborder], T_JUNCTION_PRESETS[winborder] end
|
||||
|
||||
-- Fallback to single for unknown border styles
|
||||
return BORDER_PRESETS.single, T_JUNCTION_PRESETS.single
|
||||
end
|
||||
|
||||
local function get_prompt_position()
|
||||
local config = M.state.config
|
||||
@@ -299,7 +330,7 @@ function M.create_ui()
|
||||
combo_renderer.init(M.state.ns_id)
|
||||
end
|
||||
|
||||
local debug_enabled_in_preview = M.enabled_preview() and config and config.debug and config.debug.show_scores
|
||||
local debug_enabled_in_preview = M.enabled_preview() and config and config.debug and config.debug.show_file_info
|
||||
|
||||
local terminal_width = vim.o.columns
|
||||
local terminal_height = vim.o.lines
|
||||
@@ -405,7 +436,7 @@ function M.create_ui()
|
||||
M.state.file_info_buf = nil
|
||||
end
|
||||
|
||||
-- Create list window with conditional title based on prompt position
|
||||
local border_chars, t_junctions = get_border_chars()
|
||||
local list_window_config = {
|
||||
relative = 'editor',
|
||||
width = layout.list_width,
|
||||
@@ -414,8 +445,20 @@ function M.create_ui()
|
||||
row = layout.list_row,
|
||||
-- To make the input feel connected with the picker, we customize the
|
||||
-- respective corner border characters based on prompt_position
|
||||
border = prompt_position == 'bottom' and { '┌', '─', '┐', '│', '', '', '', '│' }
|
||||
or { '├', '─', '┤', '│', '┘', '─', '└', '│' },
|
||||
-- When prompt at bottom: list has top border + sides, no bottom (connects to input below)
|
||||
-- When prompt at top: list has sides + bottom with T-junctions at top (connects to input above)
|
||||
border = prompt_position == 'bottom'
|
||||
and { border_chars[1], border_chars[2], border_chars[3], border_chars[4], '', '', '', border_chars[8] }
|
||||
or {
|
||||
t_junctions[1],
|
||||
border_chars[2],
|
||||
t_junctions[2],
|
||||
border_chars[4],
|
||||
border_chars[5],
|
||||
border_chars[6],
|
||||
border_chars[7],
|
||||
border_chars[8],
|
||||
},
|
||||
style = 'minimal',
|
||||
}
|
||||
|
||||
@@ -436,8 +479,8 @@ function M.create_ui()
|
||||
height = layout.file_info.height,
|
||||
col = layout.file_info.col,
|
||||
row = layout.file_info.row,
|
||||
border = 'single',
|
||||
style = 'minimal',
|
||||
border = border_chars,
|
||||
title = ' File Info ',
|
||||
title_pos = 'left',
|
||||
})
|
||||
@@ -453,14 +496,13 @@ function M.create_ui()
|
||||
height = layout.preview.height,
|
||||
col = layout.preview.col,
|
||||
row = layout.preview.row,
|
||||
border = 'single',
|
||||
style = 'minimal',
|
||||
border = border_chars,
|
||||
title = ' Preview ',
|
||||
title_pos = 'left',
|
||||
})
|
||||
end
|
||||
|
||||
-- Create input window with conditional title based on prompt position
|
||||
local input_window_config = {
|
||||
relative = 'editor',
|
||||
width = layout.input_width,
|
||||
@@ -469,12 +511,21 @@ function M.create_ui()
|
||||
row = layout.input_row,
|
||||
-- To make the input feel connected with the picker, we customize the
|
||||
-- respective corner border characters based on prompt_position
|
||||
border = prompt_position == 'bottom' and { '├', '─', '┤', '│', '┘', '─', '└', '│' }
|
||||
or { '┌', '─', '┐', '│', '', '', '', '│' },
|
||||
-- if prompt at bottom: input has T-junctions at top (connects to list above), full bottom border
|
||||
-- if prompt at top: input has top border + sides, no bottom (connects to list below)
|
||||
border = prompt_position == 'bottom' and {
|
||||
t_junctions[1],
|
||||
border_chars[2],
|
||||
t_junctions[2],
|
||||
border_chars[4],
|
||||
border_chars[5],
|
||||
border_chars[6],
|
||||
border_chars[7],
|
||||
border_chars[8],
|
||||
} or { border_chars[1], border_chars[2], border_chars[3], border_chars[4], '', '', '', border_chars[8] },
|
||||
style = 'minimal',
|
||||
}
|
||||
|
||||
-- Add title if prompt is at top - title appears above the prompt
|
||||
if prompt_position == 'top' then
|
||||
input_window_config.title = title
|
||||
input_window_config.title_pos = 'left'
|
||||
@@ -562,49 +613,43 @@ function M.setup_windows()
|
||||
end
|
||||
|
||||
local picker_group = vim.api.nvim_create_augroup('fff_picker_focus', { clear = true })
|
||||
local picker_windows = nil
|
||||
|
||||
if M.enabled_preview() then
|
||||
picker_windows = { M.state.input_win, M.state.preview_win, M.state.list_win }
|
||||
else
|
||||
picker_windows = { M.state.input_win, M.state.list_win }
|
||||
--- Check if a window is one of the picker windows
|
||||
--- @param win number Window handle to check
|
||||
--- @return boolean
|
||||
local function is_picker_window(win)
|
||||
if not win or not vim.api.nvim_win_is_valid(win) then return false end
|
||||
|
||||
local picker_windows = { M.state.input_win, M.state.list_win }
|
||||
if M.state.preview_win then table.insert(picker_windows, M.state.preview_win) end
|
||||
if M.state.file_info_win then table.insert(picker_windows, M.state.file_info_win) end
|
||||
|
||||
for _, picker_win in ipairs(picker_windows) do
|
||||
if picker_win and vim.api.nvim_win_is_valid(picker_win) and win == picker_win then return true end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
if M.state.preview_win then table.insert(picker_windows, M.state.preview_win) end
|
||||
if M.state.file_info_win then table.insert(picker_windows, M.state.file_info_win) end
|
||||
|
||||
vim.api.nvim_create_autocmd('WinLeave', {
|
||||
group = picker_group,
|
||||
callback = function()
|
||||
if not M.state.active then return end
|
||||
|
||||
local current_win = vim.api.nvim_get_current_win()
|
||||
local is_picker_window = false
|
||||
for _, win in ipairs(picker_windows) do
|
||||
if win and vim.api.nvim_win_is_valid(win) and current_win == win then
|
||||
is_picker_window = true
|
||||
break
|
||||
end
|
||||
end
|
||||
local leaving_win = vim.api.nvim_get_current_win()
|
||||
|
||||
-- if we current focused on picker window and leaving it
|
||||
if is_picker_window then
|
||||
vim.defer_fn(function()
|
||||
if not M.state.active then return end
|
||||
-- Only care if we're leaving a picker window
|
||||
if not is_picker_window(leaving_win) then return end
|
||||
|
||||
local new_win = vim.api.nvim_get_current_win()
|
||||
local entering_picker_window = false
|
||||
-- Schedule check to allow the window switch to complete
|
||||
vim.schedule(function()
|
||||
if not M.state.active then return end
|
||||
|
||||
for _, win in ipairs(picker_windows) do
|
||||
if win and vim.api.nvim_win_is_valid(win) and new_win == win then
|
||||
entering_picker_window = true
|
||||
break
|
||||
end
|
||||
end
|
||||
local new_win = vim.api.nvim_get_current_win()
|
||||
|
||||
if not entering_picker_window then M.close() end
|
||||
end, 10)
|
||||
end
|
||||
-- Close picker only if we moved to a non-picker window
|
||||
if not is_picker_window(new_win) then M.close() end
|
||||
end)
|
||||
end,
|
||||
desc = 'Close picker when focus leaves picker windows',
|
||||
})
|
||||
@@ -626,44 +671,104 @@ local function set_keymap(mode, keys, handler, opts)
|
||||
end
|
||||
end
|
||||
|
||||
function M.focus_list_win()
|
||||
if not M.state.active then return end
|
||||
if not M.state.list_win or not vim.api.nvim_win_is_valid(M.state.list_win) then return end
|
||||
|
||||
vim.cmd('stopinsert')
|
||||
vim.api.nvim_set_current_win(M.state.list_win)
|
||||
end
|
||||
|
||||
function M.focus_preview_win()
|
||||
if not M.state.active then return end
|
||||
if not M.state.preview_win or not vim.api.nvim_win_is_valid(M.state.preview_win) then return end
|
||||
|
||||
vim.cmd('stopinsert')
|
||||
vim.api.nvim_set_current_win(M.state.preview_win)
|
||||
end
|
||||
|
||||
local function move_list_cursor(direction)
|
||||
if not M.state.active then return end
|
||||
|
||||
local items = M.state.filtered_items
|
||||
if #items == 0 then return end
|
||||
|
||||
local new_cursor = M.state.cursor + direction
|
||||
new_cursor = math.max(1, math.min(new_cursor, #items))
|
||||
|
||||
if new_cursor ~= M.state.cursor then
|
||||
M.state.cursor = new_cursor
|
||||
M.render_list()
|
||||
M.update_preview()
|
||||
M.update_status()
|
||||
end
|
||||
end
|
||||
|
||||
function M.setup_keymaps()
|
||||
local keymaps = M.state.config.keymaps
|
||||
|
||||
local input_opts = { buffer = M.state.input_buf, noremap = true, silent = true }
|
||||
|
||||
set_keymap('i', keymaps.close, M.close, input_opts)
|
||||
set_keymap('i', keymaps.select, M.select, input_opts)
|
||||
set_keymap('i', keymaps.select_split, function() M.select('split') end, input_opts)
|
||||
set_keymap('i', keymaps.select_vsplit, function() M.select('vsplit') end, input_opts)
|
||||
set_keymap('i', keymaps.select_tab, function() M.select('tab') end, input_opts)
|
||||
set_keymap('i', keymaps.move_up, M.move_up, input_opts)
|
||||
set_keymap('i', keymaps.move_down, M.move_down, input_opts)
|
||||
set_keymap('i', keymaps.preview_scroll_up, M.scroll_preview_up, input_opts)
|
||||
set_keymap('i', keymaps.preview_scroll_down, M.scroll_preview_down, input_opts)
|
||||
set_keymap('i', keymaps.toggle_debug, M.toggle_debug, input_opts)
|
||||
set_keymap('i', keymaps.cycle_previous_query, M.recall_query_from_history, input_opts)
|
||||
set_keymap('i', keymaps.toggle_select, M.toggle_select, input_opts)
|
||||
set_keymap('i', keymaps.send_to_quickfix, M.send_to_quickfix, input_opts)
|
||||
|
||||
local list_opts = { buffer = M.state.list_buf, noremap = true, silent = true }
|
||||
|
||||
set_keymap('n', keymaps.close, M.focus_input_win, list_opts)
|
||||
vim.keymap.set('i', '<C-w>', function()
|
||||
local col = vim.fn.col('.') - 1
|
||||
local line = vim.fn.getline('.')
|
||||
local prompt_len = #M.state.config.prompt
|
||||
if col <= prompt_len then return '' end
|
||||
local text_part = line:sub(prompt_len + 1, col)
|
||||
local after_cursor = line:sub(col + 1)
|
||||
local new_text = text_part:gsub('%S*%s*$', '')
|
||||
local new_line = M.state.config.prompt .. new_text .. after_cursor
|
||||
local new_col = prompt_len + #new_text
|
||||
vim.fn.setline('.', new_line)
|
||||
vim.fn.cursor(vim.fn.line('.'), new_col + 1)
|
||||
return ''
|
||||
end, input_opts)
|
||||
|
||||
set_keymap('i', keymaps.move_up, M.move_up, input_opts)
|
||||
set_keymap('i', keymaps.move_down, M.move_down, input_opts)
|
||||
set_keymap('i', keymaps.cycle_previous_query, M.recall_query_from_history, input_opts)
|
||||
set_keymap('n', 'j', M.move_down, input_opts)
|
||||
set_keymap('n', 'k', M.move_up, input_opts)
|
||||
set_keymap('n', keymaps.focus_list, M.focus_list_win, input_opts)
|
||||
set_keymap('n', keymaps.focus_preview, M.focus_preview_win, input_opts)
|
||||
|
||||
-- Input buffer: both modes
|
||||
set_keymap({ 'i', 'n' }, keymaps.close, M.close, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.select, M.select, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.select_split, function() M.select('split') end, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.select_vsplit, function() M.select('vsplit') end, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.select_tab, function() M.select('tab') end, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.preview_scroll_up, M.scroll_preview_up, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.preview_scroll_down, M.scroll_preview_down, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.toggle_debug, M.toggle_debug, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.toggle_select, M.toggle_select, input_opts)
|
||||
set_keymap({ 'i', 'n' }, keymaps.send_to_quickfix, M.send_to_quickfix, input_opts)
|
||||
|
||||
-- List buffer
|
||||
set_keymap('n', keymaps.close, M.close, list_opts)
|
||||
set_keymap('n', 'q', M.close, list_opts)
|
||||
set_keymap('n', 'j', function() move_list_cursor(1) end, list_opts)
|
||||
set_keymap('n', 'k', function() move_list_cursor(-1) end, list_opts)
|
||||
set_keymap('n', 'i', M.focus_input_win, list_opts)
|
||||
set_keymap('n', keymaps.focus_preview, M.focus_preview_win, list_opts)
|
||||
set_keymap('n', keymaps.select, M.select, list_opts)
|
||||
set_keymap('n', keymaps.select_split, function() M.select('split') end, list_opts)
|
||||
set_keymap('n', keymaps.select_vsplit, function() M.select('vsplit') end, list_opts)
|
||||
set_keymap('n', keymaps.select_tab, function() M.select('tab') end, list_opts)
|
||||
set_keymap('n', keymaps.move_up, M.move_up, list_opts)
|
||||
set_keymap('n', keymaps.move_down, M.move_down, list_opts)
|
||||
set_keymap('n', keymaps.preview_scroll_up, M.scroll_preview_up, list_opts)
|
||||
set_keymap('n', keymaps.preview_scroll_down, M.scroll_preview_down, list_opts)
|
||||
set_keymap('n', keymaps.toggle_debug, M.toggle_debug, list_opts)
|
||||
set_keymap('n', keymaps.toggle_select, M.toggle_select, list_opts)
|
||||
set_keymap('n', keymaps.send_to_quickfix, M.send_to_quickfix, list_opts)
|
||||
|
||||
-- Preview buffer
|
||||
if M.state.preview_buf then
|
||||
local preview_opts = { buffer = M.state.preview_buf, noremap = true, silent = true }
|
||||
|
||||
set_keymap('n', keymaps.close, M.focus_input_win, preview_opts)
|
||||
set_keymap('n', keymaps.close, M.close, preview_opts)
|
||||
set_keymap('n', 'q', M.close, preview_opts)
|
||||
set_keymap('n', 'i', M.focus_input_win, preview_opts)
|
||||
set_keymap('n', keymaps.focus_list, M.focus_list_win, preview_opts)
|
||||
set_keymap('n', keymaps.select, M.select, preview_opts)
|
||||
set_keymap('n', keymaps.select_split, function() M.select('split') end, preview_opts)
|
||||
set_keymap('n', keymaps.select_vsplit, function() M.select('vsplit') end, preview_opts)
|
||||
@@ -673,26 +778,6 @@ function M.setup_keymaps()
|
||||
set_keymap('n', keymaps.send_to_quickfix, M.send_to_quickfix, preview_opts)
|
||||
end
|
||||
|
||||
vim.keymap.set('i', '<C-w>', function()
|
||||
local col = vim.fn.col('.') - 1
|
||||
local line = vim.fn.getline('.')
|
||||
local prompt_len = #M.state.config.prompt
|
||||
|
||||
if col <= prompt_len then return '' end
|
||||
|
||||
local text_part = line:sub(prompt_len + 1, col)
|
||||
local after_cursor = line:sub(col + 1)
|
||||
|
||||
local new_text = text_part:gsub('%S*%s*$', '')
|
||||
local new_line = M.state.config.prompt .. new_text .. after_cursor
|
||||
local new_col = prompt_len + #new_text
|
||||
|
||||
vim.fn.setline('.', new_line)
|
||||
vim.fn.cursor(vim.fn.line('.'), new_col + 1)
|
||||
|
||||
return '' -- Return empty string to prevent default <C-w> behavior
|
||||
end, input_opts)
|
||||
|
||||
vim.api.nvim_buf_attach(M.state.input_buf, false, {
|
||||
on_lines = function()
|
||||
vim.schedule(function() M.on_input_change() end)
|
||||
@@ -737,7 +822,6 @@ function M.toggle_debug()
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle input change
|
||||
function M.on_input_change()
|
||||
if not M.state.active then return end
|
||||
|
||||
@@ -972,38 +1056,9 @@ function M.render_debounced()
|
||||
end
|
||||
|
||||
local function shrink_path(path, max_width)
|
||||
if #path <= max_width then return path end
|
||||
|
||||
local segments = {}
|
||||
for segment in path:gmatch('[^/]+') do
|
||||
table.insert(segments, segment)
|
||||
end
|
||||
|
||||
if #segments <= 2 then
|
||||
return path -- Can't shrink further
|
||||
end
|
||||
|
||||
local first = segments[1]
|
||||
local last = segments[#segments]
|
||||
local ellipsis = '../'
|
||||
|
||||
for middle_count = #segments - 2, 1, -1 do
|
||||
local middle_parts = {}
|
||||
local start_idx = 2
|
||||
local end_idx = math.min(start_idx + middle_count - 1, #segments - 1)
|
||||
|
||||
for i = start_idx, end_idx do
|
||||
table.insert(middle_parts, segments[i])
|
||||
end
|
||||
|
||||
local middle = table.concat(middle_parts, '/')
|
||||
if middle_count < #segments - 2 then middle = middle .. ellipsis end
|
||||
|
||||
local result = first .. '/' .. middle .. '/' .. last
|
||||
if #result <= max_width then return result end
|
||||
end
|
||||
|
||||
return first .. '/' .. ellipsis .. last
|
||||
local config = conf.get()
|
||||
local strategy = config.layout and config.layout.path_shorten_strategy or 'middle_number'
|
||||
return rust.shorten_path(path, max_width, strategy)
|
||||
end
|
||||
|
||||
local function format_file_display(item, max_width)
|
||||
@@ -1015,10 +1070,11 @@ local function format_file_display(item, max_width)
|
||||
if parent_dir ~= '.' and parent_dir ~= '' then dir_path = parent_dir end
|
||||
end
|
||||
|
||||
local base_width = #filename + 1 -- filename + " "
|
||||
local path_max_width = max_width - base_width
|
||||
local filename_width = vim.fn.strdisplaywidth(filename)
|
||||
local base_width = filename_width + 1 -- filename + " "
|
||||
local path_max_width = math.max(max_width - base_width, 0)
|
||||
|
||||
if dir_path == '' then return filename, '' end
|
||||
if dir_path == '' or path_max_width == 0 then return filename, '' end
|
||||
local display_path = shrink_path(dir_path, path_max_width)
|
||||
|
||||
return filename, display_path
|
||||
@@ -1048,6 +1104,11 @@ local function build_render_context()
|
||||
local win_width = vim.api.nvim_win_get_width(M.state.list_win)
|
||||
local prompt_position = get_prompt_position()
|
||||
|
||||
-- Get actual text offset (signcolumn + foldcolumn + line numbers)
|
||||
local win_info = vim.fn.getwininfo(M.state.list_win)[1]
|
||||
local text_offset = win_info and win_info.textoff or 2
|
||||
local text_width = win_width - text_offset
|
||||
|
||||
-- Cursor validation
|
||||
if M.state.cursor < 1 then
|
||||
M.state.cursor = 1
|
||||
@@ -1087,7 +1148,7 @@ local function build_render_context()
|
||||
cursor = M.state.cursor,
|
||||
win_height = win_height,
|
||||
win_width = win_width,
|
||||
max_path_width = config.ui and config.ui.max_path_width or 80,
|
||||
max_path_width = text_width, -- Actual text area width (excluding signcolumn)
|
||||
debug_enabled = config and config.debug and config.debug.show_scores,
|
||||
prompt_position = prompt_position,
|
||||
has_combo = has_combo,
|
||||
@@ -1154,12 +1215,10 @@ local function apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
local empty_lines_needed = math.max(0, ctx.win_height - total_content_lines)
|
||||
|
||||
if empty_lines_needed > 0 then
|
||||
-- Insert empty lines at the beginning
|
||||
for i = empty_lines_needed, 1, -1 do
|
||||
for _ = empty_lines_needed, 1, -1 do
|
||||
table.insert(lines, 1, string.rep(' ', ctx.win_width + 5))
|
||||
end
|
||||
|
||||
-- Adjust item_to_lines mapping
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
if item_to_lines[i] then
|
||||
item_to_lines[i].first = item_to_lines[i].first + empty_lines_needed
|
||||
@@ -1174,22 +1233,18 @@ end
|
||||
--- @param item_to_lines table Item to lines mapping
|
||||
--- @param ctx table Render context
|
||||
local function update_buffer_and_cursor(lines, item_to_lines, ctx)
|
||||
-- Calculate cursor line position
|
||||
local cursor_line = 0
|
||||
if #ctx.items > 0 and ctx.cursor >= 1 and ctx.cursor <= #ctx.items then
|
||||
local cursor_item = item_to_lines[ctx.cursor]
|
||||
if cursor_item then cursor_line = cursor_item.last end
|
||||
end
|
||||
|
||||
-- Update buffer
|
||||
vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_lines(M.state.list_buf, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', false)
|
||||
|
||||
-- Clear existing highlights
|
||||
vim.api.nvim_buf_clear_namespace(M.state.list_buf, M.state.ns_id, 0, -1)
|
||||
|
||||
-- Position cursor
|
||||
if #ctx.items > 0 and cursor_line > 0 and cursor_line <= #lines then
|
||||
vim.api.nvim_win_set_cursor(M.state.list_win, { cursor_line, 0 })
|
||||
end
|
||||
@@ -1216,21 +1271,17 @@ local function apply_all_highlights(lines, item_to_lines, ctx)
|
||||
|
||||
if not line_content then goto continue end
|
||||
|
||||
-- Apply highlights using renderer.apply_highlights
|
||||
renderer.apply_highlights(item, ctx, i, M.state.list_buf, M.state.ns_id, line_idx, line_content)
|
||||
::continue::
|
||||
end
|
||||
end
|
||||
|
||||
-- Renders all virtual buffer overalys
|
||||
local function finalize_render(item_to_lines, ctx)
|
||||
-- Get text_len from item_to_lines if combo exists
|
||||
local combo_text_len = nil
|
||||
if ctx.combo_item_index and item_to_lines[ctx.combo_item_index] then
|
||||
combo_text_len = item_to_lines[ctx.combo_item_index].combo_header_text_len
|
||||
end
|
||||
|
||||
-- Render combo overlays
|
||||
local combo_was_hidden = combo_renderer.render_highlights_and_overlays(
|
||||
ctx.combo_item_index,
|
||||
combo_text_len or ctx.combo_header_text_len,
|
||||
@@ -1239,13 +1290,13 @@ local function finalize_render(item_to_lines, ctx)
|
||||
M.state.ns_id,
|
||||
ctx.config.hl.border,
|
||||
item_to_lines,
|
||||
ctx.prompt_position
|
||||
ctx.prompt_position,
|
||||
#ctx.items
|
||||
)
|
||||
|
||||
-- Handle combo hiding with scroll adjustment
|
||||
-- it's important part of functionality when user scrolls to the middle of the page we hide
|
||||
-- the combo overlay which leaves the gap of the internal neovim buffer, so scroll to show last item
|
||||
if combo_was_hidden and ctx.prompt_position == 'bottom' then scroll_to_bottom() end
|
||||
|
||||
-- Render scrollbar
|
||||
scrollbar.render(M.state.layout, ctx.config, M.state.list_win, M.state.pagination, ctx.prompt_position)
|
||||
end
|
||||
|
||||
@@ -1661,7 +1712,7 @@ function M.toggle_select()
|
||||
|
||||
M.render_list()
|
||||
|
||||
-- only when selecting the element not deslecting
|
||||
-- only when selecting the element not deselecting
|
||||
if not was_selected then
|
||||
if get_prompt_position() == 'bottom' then
|
||||
M.move_up()
|
||||
@@ -1679,22 +1730,20 @@ function M.send_to_quickfix()
|
||||
-- No need to filter for 'false' values because deselected files are removed from the table (set to nil)
|
||||
-- The pairs() iterator only iterates over keys that exist in the table
|
||||
-- So only selected files (value = true) will be collected here
|
||||
local selected_paths = {}
|
||||
local items_to_add = {}
|
||||
for path, _ in pairs(M.state.selected_files) do
|
||||
table.insert(selected_paths, path)
|
||||
table.insert(items_to_add, path)
|
||||
end
|
||||
|
||||
-- If no selections, use current file under cursor
|
||||
if #selected_paths == 0 then
|
||||
local items = M.state.filtered_items
|
||||
if #items > 0 and M.state.cursor <= #items then
|
||||
local item = items[M.state.cursor]
|
||||
if item and item.path then table.insert(selected_paths, item.path) end
|
||||
if #items_to_add == 0 then
|
||||
for _, item in ipairs(M.state.filtered_items) do
|
||||
if item and item.path then table.insert(items_to_add, item.path) end
|
||||
end
|
||||
end
|
||||
|
||||
-- Exit if still nothing to add
|
||||
if #selected_paths == 0 then
|
||||
if #items_to_add == 0 then
|
||||
vim.notify('No files to send to quickfix', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
@@ -1704,7 +1753,7 @@ function M.send_to_quickfix()
|
||||
|
||||
-- Build quickfix list entries
|
||||
local qf_list = {}
|
||||
for _, path in ipairs(selected_paths) do
|
||||
for _, path in ipairs(items_to_add) do
|
||||
table.insert(qf_list, {
|
||||
filename = path,
|
||||
lnum = 1,
|
||||
@@ -1717,7 +1766,7 @@ function M.send_to_quickfix()
|
||||
vim.fn.setqflist(qf_list, 'r')
|
||||
vim.cmd('copen')
|
||||
|
||||
local count = #selected_paths
|
||||
local count = #items_to_add
|
||||
vim.notify(string.format('Added %d file%s to quickfix list', count, count > 1 and 's' or ''), vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
@@ -1732,7 +1781,14 @@ function M.select(action)
|
||||
|
||||
action = action or 'edit'
|
||||
|
||||
local relative_path = vim.fn.fnamemodify(item.path, ':.')
|
||||
-- Strip Windows long path prefix (\\?\) if present.
|
||||
-- These can surface from Rust's fs::canonicalize on Windows when LongPathsEnabled is set.
|
||||
-- Neovim cannot open paths with this prefix. The Rust side uses dunce::canonicalize to avoid
|
||||
-- producing these, but we strip defensively here as well.
|
||||
local path = item.path
|
||||
if vim.startswith(path, '\\\\?\\') then path = path:sub(5) end
|
||||
|
||||
local relative_path = vim.fn.fnamemodify(path, ':.')
|
||||
local location = M.state.location -- Capture location before closing
|
||||
local query = M.state.query -- Capture query before closing for tracking
|
||||
|
||||
@@ -1858,8 +1914,14 @@ local function get_current_file_cache(base_path)
|
||||
if not stat or stat.type ~= 'file' then return nil end
|
||||
|
||||
local absolute_path = vim.fn.fnamemodify(current_file, ':p')
|
||||
local relative_path =
|
||||
vim.fn.fnamemodify(vim.fn.resolve(absolute_path), ':s?' .. vim.fn.escape(base_path, '\\') .. '/??')
|
||||
local resolved_abs = vim.fn.resolve(absolute_path)
|
||||
local resolved_base = vim.fn.resolve(base_path)
|
||||
|
||||
-- icloud direcrtoes on macos contain a lot of special characters that break
|
||||
-- the fnamemodify which have to escaped with %
|
||||
local escaped_base = resolved_base:gsub('([%%^$()%.%[%]*+%-?])', '%%%1')
|
||||
local relative_path = resolved_abs:gsub('^' .. escaped_base .. '/', '')
|
||||
if relative_path == '' or relative_path == resolved_abs then return nil end
|
||||
return relative_path
|
||||
end
|
||||
|
||||
|
||||
+30
-10
@@ -1,18 +1,31 @@
|
||||
local download = require('fff.download')
|
||||
|
||||
local is_windows = jit.os:lower() == 'windows'
|
||||
|
||||
--- @return string
|
||||
local function get_lib_extension()
|
||||
if jit.os:lower() == 'mac' or jit.os:lower() == 'osx' then return '.dylib' end
|
||||
if jit.os:lower() == 'windows' then return '.dll' end
|
||||
if is_windows then return '.dll' end
|
||||
return '.so'
|
||||
end
|
||||
|
||||
-- search for the lib in the /target/release directory with and without the lib prefix
|
||||
-- since MSVC doesn't include the prefix
|
||||
local info = debug.getinfo(1, 'S')
|
||||
local base_path = info and info.source and info.source:match('@?(.*/)') or ''
|
||||
--- Resolve a path to an absolute, clean form with native separators.
|
||||
--- Resolves `..` components and on Windows converts forward slashes to
|
||||
--- backslashes so that Windows APIs (LoadLibraryEx) can find the file.
|
||||
--- @param path string
|
||||
--- @return string
|
||||
local function resolve_path(path)
|
||||
local resolved = vim.fn.fnamemodify(path, ':p')
|
||||
if is_windows then resolved = resolved:gsub('/', '\\') end
|
||||
return resolved
|
||||
end
|
||||
|
||||
-- Fallback: if base_path is nil, try to determine from current file path
|
||||
-- Determine base_path from the location of this Lua file
|
||||
local info = debug.getinfo(1, 'S')
|
||||
-- Match both forward and backslash directory separators for cross-platform support
|
||||
local base_path = info and info.source and info.source:match('@?(.*[/\\])') or ''
|
||||
|
||||
-- Fallback: if base_path is empty, use vim APIs
|
||||
if not base_path or base_path == '' then
|
||||
base_path = vim.fn.fnamemodify(vim.fn.resolve(vim.fn.expand('<sfile>:p')), ':h') .. '/'
|
||||
end
|
||||
@@ -33,10 +46,11 @@ end
|
||||
-- load the library directly from the first valid path we find
|
||||
local function try_load_library()
|
||||
for _, path_pattern in ipairs(paths) do
|
||||
local actual_path = path_pattern:gsub('%?', 'fff_nvim')
|
||||
local actual_path = resolve_path(path_pattern:gsub('%?', 'fff_nvim'))
|
||||
local stat = vim.uv.fs_stat(actual_path)
|
||||
if stat and stat.type == 'file' then
|
||||
local loader, err = package.loadlib(actual_path, 'luaopen_fff_nvim')
|
||||
if err then return nil, string.format('Error loading library from %s: %s', actual_path, err) end
|
||||
if loader then return loader() end
|
||||
end
|
||||
end
|
||||
@@ -44,12 +58,18 @@ local function try_load_library()
|
||||
end
|
||||
|
||||
local backend, load_err = try_load_library()
|
||||
if not backend then
|
||||
if not backend or load_err then
|
||||
local resolved = {}
|
||||
for _, p in ipairs(paths) do
|
||||
table.insert(resolved, resolve_path(p:gsub('%?', 'fff_nvim')))
|
||||
end
|
||||
|
||||
local err_msg = string.format(
|
||||
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists with `cargo build --release`',
|
||||
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists or make it exists using \n `:lua require("fff.download").download_or_build_binary()`\nor\n`cargo build --release`\n(and rerun neovim after)',
|
||||
tostring(load_err),
|
||||
vim.inspect(paths)
|
||||
vim.inspect(resolved)
|
||||
)
|
||||
|
||||
error(err_msg)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::{FilePicker, FuzzySearchOptions};
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::query_tracker::QueryTracker;
|
||||
use crate::types::PaginationArgs;
|
||||
use mlua::prelude::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
mod background_watcher;
|
||||
mod error;
|
||||
pub mod file_picker;
|
||||
mod frecency;
|
||||
pub mod git;
|
||||
mod location;
|
||||
mod log;
|
||||
mod path_utils;
|
||||
pub mod query_tracker;
|
||||
pub mod score;
|
||||
mod sort_buffer;
|
||||
pub mod types;
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MiMalloc = MiMalloc;
|
||||
|
||||
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
|
||||
pub static QUERY_TRACKER: Lazy<RwLock<Option<QueryTracker>>> = Lazy::new(|| RwLock::new(None));
|
||||
|
||||
pub fn init_db(
|
||||
_: &Lua,
|
||||
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
|
||||
) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
if frecency.is_some() {
|
||||
*frecency = None;
|
||||
}
|
||||
*frecency = Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock)?);
|
||||
tracing::info!("Frecency database initialized at {}", frecency_db_path);
|
||||
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
if query_tracker.is_some() {
|
||||
*query_tracker = None;
|
||||
}
|
||||
|
||||
let tracker = QueryTracker::new(&history_db_path, use_unsafe_no_lock)?;
|
||||
*query_tracker = Some(tracker);
|
||||
tracing::info!("Query tracker database initialized at {}", history_db_path);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn destroy_frecency_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
*frecency = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut query_tracker = QUERY_TRACKER
|
||||
.write()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
*query_tracker = None;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
if file_picker.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let picker = FilePicker::new(base_path)?;
|
||||
*file_picker = Some(picker);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
|
||||
// drop should clean it anyway but just to be extra sure
|
||||
if let Some(mut picker) = file_picker.take() {
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
|
||||
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
|
||||
*file_picker = Some(new_picker);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
let path = std::path::PathBuf::from(&new_path);
|
||||
if !path.exists() {
|
||||
return Err(LuaError::RuntimeError(format!(
|
||||
"Path does not exist: {}",
|
||||
new_path
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_path = path.canonicalize().map_err(|e| {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
// Spawn a background thread to avoid blocking Lua/UI thread
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
|
||||
::tracing::error!(
|
||||
?e,
|
||||
?canonical_path,
|
||||
"Failed to index directory after changing"
|
||||
);
|
||||
} else {
|
||||
::tracing::info!(?canonical_path, "Successfully reindexed directory");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
|
||||
picker.trigger_rescan()?;
|
||||
::tracing::info!("scan_files trigger_rescan completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn fuzzy_search_files(
|
||||
lua: &Lua,
|
||||
(
|
||||
query,
|
||||
max_threads,
|
||||
current_file,
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
page_index,
|
||||
page_size,
|
||||
): (
|
||||
String,
|
||||
usize,
|
||||
Option<String>,
|
||||
i32,
|
||||
Option<u32>,
|
||||
Option<usize>,
|
||||
Option<usize>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
|
||||
let base_path = picker.base_path();
|
||||
let min_combo_count = min_combo_count.unwrap_or(3);
|
||||
|
||||
let last_same_query_entry = {
|
||||
let query_tracker = QUERY_TRACKER
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?;
|
||||
|
||||
if query_tracker.as_ref().is_none() {
|
||||
tracing::warn!("Query tracker not initialized");
|
||||
}
|
||||
|
||||
query_tracker
|
||||
.as_ref()
|
||||
.map(|tracker| tracker.get_last_query_entry(&query, base_path, min_combo_count))
|
||||
.transpose()?
|
||||
.flatten()
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
?last_same_query_entry,
|
||||
?base_path,
|
||||
?query,
|
||||
?min_combo_count,
|
||||
?page_index,
|
||||
?page_size,
|
||||
"Fuzzy search parameters"
|
||||
);
|
||||
|
||||
let results = FilePicker::fuzzy_search(
|
||||
picker.get_files(),
|
||||
&query,
|
||||
FuzzySearchOptions {
|
||||
max_threads,
|
||||
current_file: current_file.as_deref(),
|
||||
project_path: Some(picker.base_path()),
|
||||
last_same_query_match: last_same_query_entry.as_ref(),
|
||||
combo_boost_score_multiplier,
|
||||
min_combo_count,
|
||||
pagination: PaginationArgs {
|
||||
offset: page_index.unwrap_or(0),
|
||||
limit: page_size.unwrap_or(0),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
results.into_lua(lua)
|
||||
}
|
||||
|
||||
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let file_path = PathBuf::from(&file_path);
|
||||
|
||||
// Track access in frecency DB (expensive LMDB write, ~100-200ms)
|
||||
// Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches
|
||||
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
frecency.track_access(file_path.as_path())?;
|
||||
|
||||
// Quick lock to update single file's frecency score in picker
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
picker.update_single_file_frecency(&file_path, frecency)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
let progress = picker.get_scan_progress();
|
||||
|
||||
let table = lua.create_table()?;
|
||||
table.set("scanned_files_count", progress.scanned_files_count)?;
|
||||
table.set("is_scanning", progress.is_scanning)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
Ok(picker.is_scan_active())
|
||||
}
|
||||
|
||||
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
|
||||
FilePicker::refresh_git_status_global().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
|
||||
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
|
||||
picker.update_single_file_frecency(&file_path, frecency)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
|
||||
picker.stop_background_monitor();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
|
||||
if let Some(picker) = file_picker.take() {
|
||||
drop(picker);
|
||||
::tracing::info!("FilePicker cleanup completed");
|
||||
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
|
||||
// Get the project path before spawning thread
|
||||
let project_path = {
|
||||
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
// Canonicalize the file path before spawning thread
|
||||
let file_path = match PathBuf::from(&file_path).canonicalize() {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Spawn background thread to do the actual tracking (expensive DB write)
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
|
||||
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
|
||||
{
|
||||
tracing::error!(
|
||||
query = %query,
|
||||
file = %file_path.display(),
|
||||
error = ?e,
|
||||
"Failed to track query completion"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
|
||||
let project_path = {
|
||||
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
picker.base_path().to_path_buf()
|
||||
};
|
||||
|
||||
let Some(ref tracker) = *QUERY_TRACKER
|
||||
.read()
|
||||
.map_err(|_| Error::AcquireFrecencyLock)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tracker
|
||||
.get_historical_query(&project_path, offset)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
|
||||
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
|
||||
let picker = file_picker
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::FilePickerMissing)?;
|
||||
|
||||
let timeout_ms = timeout_ms.unwrap_or(500);
|
||||
let timeout_duration = Duration::from_millis(timeout_ms);
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut sleep_duration = Duration::from_millis(1);
|
||||
|
||||
while picker.is_scan_active() {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
std::thread::sleep(sleep_duration);
|
||||
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
|
||||
}
|
||||
|
||||
::tracing::debug!(
|
||||
"wait_for_initial_scan completed in {:?}",
|
||||
start_time.elapsed()
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn init_tracing(
|
||||
_: &Lua,
|
||||
(log_file_path, log_level): (String, Option<String>),
|
||||
) -> LuaResult<String> {
|
||||
crate::log::init_tracing(&log_file_path, log_level.as_deref())
|
||||
.map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e)))
|
||||
}
|
||||
|
||||
fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
let exports = lua.create_table()?;
|
||||
exports.set("init_db", lua.create_function(init_db)?)?;
|
||||
exports.set(
|
||||
"destroy_frecency_db",
|
||||
lua.create_function(destroy_frecency_db)?,
|
||||
)?;
|
||||
exports.set("init_file_picker", lua.create_function(init_file_picker)?)?;
|
||||
exports.set(
|
||||
"restart_index_in_path",
|
||||
lua.create_function(restart_index_in_path)?,
|
||||
)?;
|
||||
exports.set("scan_files", lua.create_function(scan_files)?)?;
|
||||
exports.set(
|
||||
"fuzzy_search_files",
|
||||
lua.create_function(fuzzy_search_files)?,
|
||||
)?;
|
||||
exports.set("track_access", lua.create_function(track_access)?)?;
|
||||
exports.set("cancel_scan", lua.create_function(cancel_scan)?)?;
|
||||
exports.set("get_scan_progress", lua.create_function(get_scan_progress)?)?;
|
||||
exports.set(
|
||||
"refresh_git_status",
|
||||
lua.create_function(refresh_git_status)?,
|
||||
)?;
|
||||
exports.set(
|
||||
"stop_background_monitor",
|
||||
lua.create_function(stop_background_monitor)?,
|
||||
)?;
|
||||
exports.set("init_tracing", lua.create_function(init_tracing)?)?;
|
||||
exports.set(
|
||||
"wait_for_initial_scan",
|
||||
lua.create_function(wait_for_initial_scan)?,
|
||||
)?;
|
||||
exports.set(
|
||||
"cleanup_file_picker",
|
||||
lua.create_function(cleanup_file_picker)?,
|
||||
)?;
|
||||
exports.set("destroy_query_db", lua.create_function(destroy_query_db)?)?;
|
||||
exports.set(
|
||||
"track_query_completion",
|
||||
lua.create_function(track_query_completion)?,
|
||||
)?;
|
||||
exports.set(
|
||||
"get_historical_query",
|
||||
lua.create_function(get_historical_query)?,
|
||||
)?;
|
||||
|
||||
Ok(exports)
|
||||
}
|
||||
|
||||
// https://github.com/mlua-rs/mlua/issues/318
|
||||
#[mlua::lua_module(skip_memory_check)]
|
||||
fn fff_nvim(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
// Install panic hook IMMEDIATELY on module load
|
||||
// This ensures any panics are logged even if init_tracing is never called
|
||||
crate::log::install_panic_hook();
|
||||
|
||||
create_exports(lua)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
use mlua::prelude::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{git::format_git_status, location::Location, query_tracker::QueryMatchEntry};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileItem {
|
||||
pub path: PathBuf,
|
||||
pub relative_path: String,
|
||||
pub relative_path_lower: String,
|
||||
pub file_name: String,
|
||||
pub file_name_lower: String,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
pub access_frecency_score: i64,
|
||||
pub modification_frecency_score: i64,
|
||||
pub total_frecency_score: i64,
|
||||
pub git_status: Option<git2::Status>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Score {
|
||||
pub total: i32,
|
||||
pub base_score: i32,
|
||||
pub filename_bonus: i32,
|
||||
pub special_filename_bonus: i32,
|
||||
pub frecency_boost: i32,
|
||||
pub distance_penalty: i32,
|
||||
pub current_file_penalty: i32,
|
||||
pub combo_match_boost: i32,
|
||||
pub exact_match: bool,
|
||||
pub match_type: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PaginationArgs {
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoringContext<'a> {
|
||||
pub query: &'a str,
|
||||
pub project_path: Option<&'a Path>,
|
||||
pub current_file: Option<&'a str>,
|
||||
pub max_typos: u16,
|
||||
pub max_threads: usize,
|
||||
pub last_same_query_match: Option<&'a QueryMatchEntry>,
|
||||
pub combo_boost_score_multiplier: i32,
|
||||
pub min_combo_count: u32,
|
||||
pub pagination: PaginationArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SearchResult<'a> {
|
||||
pub items: Vec<&'a FileItem>,
|
||||
pub scores: Vec<Score>,
|
||||
pub total_matched: usize,
|
||||
pub total_files: usize,
|
||||
pub location: Option<Location>,
|
||||
}
|
||||
|
||||
impl IntoLua for &FileItem {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("path", self.path.to_string_lossy().to_string())?;
|
||||
table.set("relative_path", self.relative_path.clone())?;
|
||||
table.set("name", self.file_name.clone())?;
|
||||
table.set("size", self.size)?;
|
||||
table.set("modified", self.modified)?;
|
||||
table.set("access_frecency_score", self.access_frecency_score)?;
|
||||
table.set(
|
||||
"modification_frecency_score",
|
||||
self.modification_frecency_score,
|
||||
)?;
|
||||
table.set("total_frecency_score", self.total_frecency_score)?;
|
||||
table.set("git_status", format_git_status(self.git_status))?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoLua for Score {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("total", self.total)?;
|
||||
table.set("base_score", self.base_score)?;
|
||||
table.set("filename_bonus", self.filename_bonus)?;
|
||||
table.set("special_filename_bonus", self.special_filename_bonus)?;
|
||||
table.set("frecency_boost", self.frecency_boost)?;
|
||||
table.set("distance_penalty", self.distance_penalty)?;
|
||||
table.set("current_file_penalty", self.current_file_penalty)?;
|
||||
table.set("combo_match_boost", self.combo_match_boost)?;
|
||||
table.set("match_type", self.match_type)?;
|
||||
table.set("exact_match", self.exact_match)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
|
||||
struct LuaPosition((i32, i32));
|
||||
|
||||
impl IntoLua for LuaPosition {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("line", self.0.0)?;
|
||||
table.set("col", self.0.1)?;
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoLua for SearchResult<'_> {
|
||||
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("items", self.items)?;
|
||||
table.set("scores", self.scores)?;
|
||||
table.set("total_matched", self.total_matched)?;
|
||||
table.set("total_files", self.total_files)?;
|
||||
|
||||
if let Some(location) = &self.location {
|
||||
let location_table = lua.create_table()?;
|
||||
|
||||
match location {
|
||||
Location::Line(line) => {
|
||||
location_table.set("line", *line)?;
|
||||
}
|
||||
Location::Position { line, col } => {
|
||||
location_table.set("line", *line)?;
|
||||
location_table.set("col", *col)?;
|
||||
}
|
||||
Location::Range { start, end } => {
|
||||
location_table.set("start", LuaPosition(*start))?;
|
||||
location_table.set("end", LuaPosition(*end))?;
|
||||
}
|
||||
}
|
||||
|
||||
table.set("location", location_table)?;
|
||||
}
|
||||
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
local M = {}
|
||||
|
||||
function M.mkdir_recursive(path, callback)
|
||||
vim.uv.fs_stat(path, function(err, stat)
|
||||
if not err and stat then
|
||||
callback(true, nil)
|
||||
return
|
||||
end
|
||||
|
||||
local parent = vim.fn.fnamemodify(path, ':h')
|
||||
if parent == path or parent == '' or parent == '.' then
|
||||
callback(false, 'Cannot create root directory')
|
||||
return
|
||||
end
|
||||
|
||||
M.mkdir_recursive(parent, function(parent_ok, parent_err)
|
||||
if not parent_ok then
|
||||
callback(false, parent_err)
|
||||
return
|
||||
end
|
||||
|
||||
vim.uv.fs_mkdir(path, 493, function(mkdir_err) -- 493 = 0755 octal
|
||||
if mkdir_err and not mkdir_err:match('EEXIST') then
|
||||
callback(false, 'Failed to create directory: ' .. mkdir_err)
|
||||
return
|
||||
end
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,10 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Native binaries (downloaded at install)
|
||||
bin/*.dylib
|
||||
bin/*.so
|
||||
bin/*.dll
|
||||
@@ -0,0 +1,198 @@
|
||||
# fff - Fast File Finder
|
||||
|
||||
High-performance fuzzy file finder for Bun, powered by Rust. Perfect for LLM agent tools that need to search through codebases.
|
||||
|
||||
## Features
|
||||
|
||||
- **Blazing fast** - Rust-powered fuzzy search with parallel processing
|
||||
- **Smart ranking** - Frecency-based scoring (frequency + recency)
|
||||
- **Git-aware** - Shows file git status in results
|
||||
- **Query history** - Learns from your search patterns
|
||||
- **Type-safe** - Full TypeScript support with Result types
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @ff-labs/bun
|
||||
```
|
||||
|
||||
The native binary will be downloaded automatically during installation.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { FileFinder } from "fff";
|
||||
|
||||
// Initialize with a directory
|
||||
const result = FileFinder.init({ basePath: "/path/to/project" });
|
||||
if (!result.ok) {
|
||||
console.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Wait for initial scan
|
||||
FileFinder.waitForScan(5000);
|
||||
|
||||
// Search for files
|
||||
const search = FileFinder.search("main.ts");
|
||||
if (search.ok) {
|
||||
for (const item of search.value.items) {
|
||||
console.log(item.relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup when done
|
||||
FileFinder.destroy();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FileFinder.init(options)`
|
||||
|
||||
Initialize the file finder.
|
||||
|
||||
```typescript
|
||||
interface InitOptions {
|
||||
basePath: string; // Directory to index (required)
|
||||
frecencyDbPath?: string; // Frecency DB path (omit to skip frecency)
|
||||
historyDbPath?: string; // History DB path (omit to skip query tracking)
|
||||
useUnsafeNoLock?: boolean; // Faster but less safe DB mode
|
||||
}
|
||||
|
||||
const result = FileFinder.init({ basePath: "/my/project" });
|
||||
```
|
||||
|
||||
### `FileFinder.search(query, options?)`
|
||||
|
||||
Search for files.
|
||||
|
||||
```typescript
|
||||
interface SearchOptions {
|
||||
maxThreads?: number; // Parallel threads (0 = auto)
|
||||
currentFile?: string; // Deprioritize this file
|
||||
comboBoostMultiplier?: number; // Query history boost
|
||||
minComboCount?: number; // Min history matches
|
||||
pageIndex?: number; // Pagination offset
|
||||
pageSize?: number; // Results per page
|
||||
}
|
||||
|
||||
const result = FileFinder.search("main.ts", { pageSize: 10 });
|
||||
if (result.ok) {
|
||||
console.log(`Found ${result.value.totalMatched} files`);
|
||||
}
|
||||
```
|
||||
|
||||
### Query Syntax
|
||||
|
||||
- `foo bar` - Match files containing "foo" and "bar"
|
||||
- `src/` - Match files in src directory
|
||||
- `file.ts:42` - Match file.ts with line 42
|
||||
- `file.ts:42:10` - Match with line and column
|
||||
|
||||
### `FileFinder.trackAccess(filePath)`
|
||||
|
||||
Track file access for frecency scoring.
|
||||
|
||||
```typescript
|
||||
// Call when user opens a file
|
||||
FileFinder.trackAccess("/path/to/file.ts");
|
||||
```
|
||||
|
||||
### `FileFinder.trackQuery(query, selectedFile)`
|
||||
|
||||
Track query completion for smart suggestions.
|
||||
|
||||
```typescript
|
||||
// Call when user selects a file from search
|
||||
FileFinder.trackQuery("main", "/path/to/main.ts");
|
||||
```
|
||||
|
||||
### `FileFinder.healthCheck(testPath?)`
|
||||
|
||||
Get diagnostic information.
|
||||
|
||||
```typescript
|
||||
const health = FileFinder.healthCheck();
|
||||
if (health.ok) {
|
||||
console.log(`Version: ${health.value.version}`);
|
||||
console.log(`Indexed: ${health.value.filePicker.indexedFiles} files`);
|
||||
}
|
||||
```
|
||||
|
||||
### Other Methods
|
||||
|
||||
- `FileFinder.scanFiles()` - Trigger rescan
|
||||
- `FileFinder.isScanning()` - Check scan status
|
||||
- `FileFinder.getScanProgress()` - Get scan progress
|
||||
- `FileFinder.waitForScan(timeoutMs)` - Wait for scan
|
||||
- `FileFinder.reindex(newPath)` - Change indexed directory
|
||||
- `FileFinder.refreshGitStatus()` - Refresh git cache
|
||||
- `FileFinder.getHistoricalQuery(offset)` - Get past queries
|
||||
- `FileFinder.destroy()` - Cleanup resources
|
||||
|
||||
## Result Types
|
||||
|
||||
All methods return a `Result<T>` type for explicit error handling:
|
||||
|
||||
```typescript
|
||||
type Result<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const result = FileFinder.search("foo");
|
||||
if (result.ok) {
|
||||
// result.value is SearchResult
|
||||
} else {
|
||||
// result.error is string
|
||||
}
|
||||
```
|
||||
|
||||
## Search Result Types
|
||||
|
||||
```typescript
|
||||
interface SearchResult {
|
||||
items: FileItem[];
|
||||
scores: Score[];
|
||||
totalMatched: number;
|
||||
totalFiles: number;
|
||||
location?: Location;
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
modified: number;
|
||||
gitStatus: string; // 'clean', 'modified', 'untracked', etc.
|
||||
}
|
||||
```
|
||||
|
||||
## Building from Source
|
||||
|
||||
If prebuilt binaries aren't available for your platform:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/dmtrKovalenko/fff.nvim
|
||||
cd fff.nvim
|
||||
|
||||
# Build the C library
|
||||
cargo build --release -p fff-c
|
||||
|
||||
# The binary will be at target/release/libfff_c.{so,dylib,dll}
|
||||
```
|
||||
|
||||
## CLI Tools
|
||||
|
||||
```bash
|
||||
# Download binary manually
|
||||
bunx fff download [version]
|
||||
|
||||
# Show platform info
|
||||
bunx fff info
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "fff",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw=="],
|
||||
|
||||
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-YiLxfsPzQqaVvT2a+nxH9do0YfUjrlxF3tKP0b1DDgvfgCcVKGsrQH3Wa82qHgL4dnT8h2bqi94JxXESEuPmcA=="],
|
||||
|
||||
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XbhsA2XAFzvFr0vPSV6SNqGxab4xHKdPmVTLqoSHAx9tffrSq/012BDptOskulwnD+YNsrJUx2D2Ve1xvfgGcg=="],
|
||||
|
||||
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-VaNQTu0Up4gnwZLQ6/Hmho6jAlLxTQ1PwxEth8EsXHf82FOXXPV5OCQ6KC9mmmocjKlmWFaIGebThrOy8DUo4g=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-t8uimCVBTw5f9K2QTZE5wN6UOrFETNrh/Xr7qtXT9nAOzaOnIFvYA+HcHbGfi31fRlCVfTxqm/EiCwJ1gEw9YQ=="],
|
||||
|
||||
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-oQyAW3+ugulvXTZ+XYeUMmNPR94sJeMokfHQoKwPvVwhVkgRuMhcLGV2ZesHCADVu30Oz2MFXbgdC8x4/o9dRg=="],
|
||||
|
||||
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-nZ12g22cy7pEOBwAxz2tp0wVqekaCn9QRKuGTHqOdLlyAqR4SCdErDvDhUWd51bIyHTQoCmj72TegGTgG0WNPw=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-4ZjIUgCxEyKwcKXideB5sX0KJpnHTZtu778w73VNq2uNH2fNpMZv98+DBgJyQ9OfFoRhmKn1bmLmSefvnHzI9w=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-3FXQgtYFsT0YOmAdMcJn56pLM5kzSl6y942rJJIl5l2KummB9Ea3J/vMJMzQk7NCAGhleZGWU/pJSS/uXKGa7w=="],
|
||||
|
||||
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/d6vAmgKvkoYlsGPsRPlPmOK1slPis/F40UG02pYwypTH0wmY0smgzdFqR4YmryxFh17XrW1kITv+U99Oajk9Q=="],
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-a/+hSrrDpMD7THyXvE2KJy1skxzAD0cnW4K1WjuI/91VqsphjNzvf5t/ZgxEVL4wb6f+hKrSJ5J3aH47zPr61g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
|
||||
|
||||
"bun": ["bun@1.3.9", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.9", "@oven/bun-darwin-x64": "1.3.9", "@oven/bun-darwin-x64-baseline": "1.3.9", "@oven/bun-linux-aarch64": "1.3.9", "@oven/bun-linux-aarch64-musl": "1.3.9", "@oven/bun-linux-x64": "1.3.9", "@oven/bun-linux-x64-baseline": "1.3.9", "@oven/bun-linux-x64-musl": "1.3.9", "@oven/bun-linux-x64-musl-baseline": "1.3.9", "@oven/bun-windows-x64": "1.3.9", "@oven/bun-windows-x64-baseline": "1.3.9" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Interactive file finder demo
|
||||
*
|
||||
* Usage:
|
||||
* bunx fff-demo [directory]
|
||||
* bun examples/search.ts [directory]
|
||||
*
|
||||
* Indexes the specified directory (or cwd) and provides an interactive
|
||||
* search prompt with detailed metadata about results.
|
||||
*/
|
||||
|
||||
import { FileFinder } from "../src/index";
|
||||
import * as readline from "readline";
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
const BOLD = "\x1b[1m";
|
||||
const DIM = "\x1b[2m";
|
||||
const GREEN = "\x1b[32m";
|
||||
const YELLOW = "\x1b[33m";
|
||||
const BLUE = "\x1b[34m";
|
||||
const MAGENTA = "\x1b[35m";
|
||||
const CYAN = "\x1b[36m";
|
||||
const RED = "\x1b[31m";
|
||||
|
||||
function formatGitStatus(status: string): string {
|
||||
switch (status) {
|
||||
case "modified":
|
||||
return `${YELLOW}M${RESET}`;
|
||||
case "untracked":
|
||||
return `${GREEN}?${RESET}`;
|
||||
case "added":
|
||||
return `${GREEN}A${RESET}`;
|
||||
case "deleted":
|
||||
return `${RED}D${RESET}`;
|
||||
case "renamed":
|
||||
return `${BLUE}R${RESET}`;
|
||||
case "clear":
|
||||
case "current":
|
||||
return `${DIM} ${RESET}`;
|
||||
default:
|
||||
return `${DIM}${status.charAt(0)}${RESET}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatScore(score: number): string {
|
||||
if (score >= 100) return `${GREEN}${score}${RESET}`;
|
||||
if (score >= 50) return `${YELLOW}${score}${RESET}`;
|
||||
if (score > 0) return `${DIM}${score}${RESET}`;
|
||||
return `${DIM}0${RESET}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function formatTime(unixSeconds: number): string {
|
||||
if (unixSeconds === 0) return "unknown";
|
||||
const date = new Date(unixSeconds * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const targetDir = process.argv[2] || process.cwd();
|
||||
|
||||
console.log(`${BOLD}${CYAN}fff - Fast File Finder Demo${RESET}\n`);
|
||||
|
||||
// Check library availability
|
||||
if (!FileFinder.isAvailable()) {
|
||||
console.error(`${RED}Error: Native library not found.${RESET}`);
|
||||
console.error("Build with: cargo build --release -p fff-c");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
console.log(`${DIM}Initializing index for: ${targetDir}${RESET}`);
|
||||
const initResult = FileFinder.init({
|
||||
basePath: targetDir,
|
||||
});
|
||||
|
||||
if (!initResult.ok) {
|
||||
console.error(`${RED}Init failed: ${initResult.error}${RESET}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Wait for scan with progress
|
||||
process.stdout.write(`${DIM}Scanning files...${RESET}`);
|
||||
const startTime = Date.now();
|
||||
let lastCount = 0;
|
||||
|
||||
while (FileFinder.isScanning()) {
|
||||
const progress = FileFinder.getScanProgress();
|
||||
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
|
||||
lastCount = progress.value.scannedFilesCount;
|
||||
process.stdout.write(`\r${DIM}Scanning files... ${lastCount}${RESET} `);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
const scanTime = Date.now() - startTime;
|
||||
const finalProgress = FileFinder.getScanProgress();
|
||||
const totalFiles = finalProgress.ok ? finalProgress.value.scannedFilesCount : 0;
|
||||
|
||||
console.log(`\r${GREEN}✓${RESET} Indexed ${BOLD}${totalFiles}${RESET} files in ${scanTime}ms\n`);
|
||||
|
||||
// Show index info
|
||||
const health = FileFinder.healthCheck();
|
||||
if (health.ok) {
|
||||
console.log(`${DIM}─────────────────────────────────────────${RESET}`);
|
||||
console.log(`${DIM}Version:${RESET} ${health.value.version}`);
|
||||
console.log(`${DIM}Base path:${RESET} ${health.value.filePicker.basePath}`);
|
||||
if (health.value.git.repositoryFound) {
|
||||
console.log(`${DIM}Git root:${RESET} ${health.value.git.workdir}`);
|
||||
}
|
||||
console.log(`${DIM}─────────────────────────────────────────${RESET}\n`);
|
||||
}
|
||||
|
||||
// Interactive search loop
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
console.log(`${BOLD}Enter a search query${RESET} (or 'q' to quit, empty for all files):\n`);
|
||||
|
||||
const prompt = () => {
|
||||
rl.question(`${CYAN}search>${RESET} `, (query) => {
|
||||
if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
|
||||
console.log(`\n${DIM}Goodbye!${RESET}`);
|
||||
FileFinder.destroy();
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const searchStart = Date.now();
|
||||
const result = FileFinder.search(query, { pageSize: 15 });
|
||||
const searchTime = Date.now() - searchStart;
|
||||
|
||||
if (!result.ok) {
|
||||
console.log(`${RED}Search error: ${result.error}${RESET}\n`);
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
const { items, scores, totalMatched, totalFiles } = result.value;
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
`${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalFiles} files (${searchTime}ms)${RESET}`
|
||||
);
|
||||
console.log();
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log(`${DIM}No matches found.${RESET}\n`);
|
||||
prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
// Header
|
||||
console.log(
|
||||
`${DIM} Git │ Score │ Size │ Modified │ Path${RESET}`
|
||||
);
|
||||
console.log(`${DIM}──────┼───────┼────────┼────────────┼${"─".repeat(40)}${RESET}`);
|
||||
|
||||
// Results
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const score = scores[i];
|
||||
|
||||
const gitStatus = formatGitStatus(item.gitStatus);
|
||||
const totalScore = formatScore(score.total);
|
||||
const size = formatSize(item.size).padStart(6);
|
||||
const modified = formatTime(item.modified).padEnd(10);
|
||||
const path = item.relativePath;
|
||||
|
||||
console.log(
|
||||
` ${gitStatus} │ ${totalScore.padStart(5)} │ ${size} │ ${modified} │ ${path}`
|
||||
);
|
||||
|
||||
// Show score breakdown for top results
|
||||
if (i < 3 && score.total > 0) {
|
||||
const breakdown: string[] = [];
|
||||
if (score.baseScore > 0) breakdown.push(`base:${score.baseScore}`);
|
||||
if (score.filenameBonus > 0) breakdown.push(`filename:+${score.filenameBonus}`);
|
||||
if (score.frecencyBoost > 0) breakdown.push(`frecency:+${score.frecencyBoost}`);
|
||||
if (score.comboMatchBoost > 0) breakdown.push(`combo:+${score.comboMatchBoost}`);
|
||||
if (score.distancePenalty < 0) breakdown.push(`distance:${score.distancePenalty}`);
|
||||
if (score.exactMatch) breakdown.push(`${GREEN}exact${RESET}`);
|
||||
|
||||
if (breakdown.length > 0) {
|
||||
console.log(`${DIM} │ │ │ │ └─ ${breakdown.join(", ")}${RESET}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalMatched > items.length) {
|
||||
console.log(
|
||||
`${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log();
|
||||
prompt();
|
||||
});
|
||||
};
|
||||
|
||||
prompt();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`${RED}Fatal error: ${err.message}${RESET}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "@ff-labs/bun",
|
||||
"version": "0.1.37",
|
||||
"private": false,
|
||||
"nativeBinaryHash": "1537fc7",
|
||||
"description": "High-performance fuzzy file finder for Bun - perfect for LLM agent tools",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"fff": "./scripts/cli.ts",
|
||||
"fff-demo": "./examples/search.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"bin",
|
||||
"scripts",
|
||||
"examples"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "bun ./scripts/postinstall.ts",
|
||||
"download": "bun ./scripts/cli.ts download",
|
||||
"test": "bun test src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"demo": "bun ./examples/search.ts"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.0.0"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.nvim.git",
|
||||
"directory": "packages/fff"
|
||||
},
|
||||
"keywords": [
|
||||
"file-finder",
|
||||
"fuzzy-search",
|
||||
"bun",
|
||||
"ffi",
|
||||
"llm-tools",
|
||||
"agent-tools",
|
||||
"fast",
|
||||
"rust"
|
||||
],
|
||||
"author": "Dmitry Kovalenko",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/dmtrKovalenko/fff.nvim/issues"
|
||||
},
|
||||
"homepage": "https://github.com/dmtrKovalenko/fff.nvim#readme",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.8",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0"
|
||||
}
|
||||
}
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* CLI tool for fff package management
|
||||
*
|
||||
* Usage:
|
||||
* bunx fff download [hash] - Download native binary
|
||||
* bunx fff info - Show platform and binary info
|
||||
* bunx fff check - Check for updates
|
||||
*/
|
||||
|
||||
import {
|
||||
downloadBinary,
|
||||
getBinaryPath,
|
||||
findBinary,
|
||||
getInstalledHash,
|
||||
checkForUpdate
|
||||
} from "../src/download";
|
||||
import { getTriple, getLibExtension, getLibFilename } from "../src/platform";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
interface PackageJson {
|
||||
version: string;
|
||||
nativeBinaryHash?: string;
|
||||
}
|
||||
|
||||
async function getPackageInfo(): Promise<PackageJson> {
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const packageJsonPath = join(currentDir, "..", "package.json");
|
||||
|
||||
try {
|
||||
return await Bun.file(packageJsonPath).json();
|
||||
} catch {
|
||||
return { version: "unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
switch (command) {
|
||||
case "download": {
|
||||
const hash = args[1];
|
||||
console.log("fff: Downloading native library...");
|
||||
try {
|
||||
const resolvedHash = await downloadBinary(hash);
|
||||
console.log(`fff: Download complete! (${resolvedHash})`);
|
||||
} catch (error) {
|
||||
console.error("fff: Download failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "check": {
|
||||
console.log("fff: Checking for updates...");
|
||||
try {
|
||||
const { currentHash, latestHash, updateAvailable } = await checkForUpdate();
|
||||
console.log(` Installed: ${currentHash || "not installed"}`);
|
||||
console.log(` Latest: ${latestHash}`);
|
||||
if (updateAvailable) {
|
||||
console.log("");
|
||||
console.log(" Update available! Run: bunx fff download");
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(" You're up to date!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("fff: Failed to check for updates:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "info": {
|
||||
const pkg = await getPackageInfo();
|
||||
const installedHash = await getInstalledHash();
|
||||
|
||||
console.log("fff - Fast File Finder");
|
||||
console.log(`Package version: ${pkg.version}`);
|
||||
console.log(`Binary hash: ${installedHash || "not installed"}`);
|
||||
console.log("");
|
||||
console.log("Platform Information:");
|
||||
console.log(` Triple: ${getTriple()}`);
|
||||
console.log(` Extension: ${getLibExtension()}`);
|
||||
console.log(` Library name: ${getLibFilename()}`);
|
||||
console.log("");
|
||||
console.log("Binary Status:");
|
||||
const existing = findBinary();
|
||||
if (existing) {
|
||||
console.log(` Found: ${existing}`);
|
||||
} else {
|
||||
console.log(` Not found`);
|
||||
console.log(` Expected path: ${getBinaryPath()}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "version":
|
||||
case "--version":
|
||||
case "-v": {
|
||||
const pkg = await getPackageInfo();
|
||||
console.log(pkg.version);
|
||||
break;
|
||||
}
|
||||
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
default: {
|
||||
const pkg = await getPackageInfo();
|
||||
console.log(`fff - Fast File Finder CLI v${pkg.version}`);
|
||||
console.log("");
|
||||
console.log("Usage:");
|
||||
console.log(" bunx fff download [hash] Download native binary");
|
||||
console.log(" bunx fff check Check for updates");
|
||||
console.log(" bunx fff info Show platform and binary info");
|
||||
console.log(" bunx fff version Show version");
|
||||
console.log(" bunx fff help Show this help message");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" bunx fff download Download binary for configured hash");
|
||||
console.log(" bunx fff download latest Download latest release");
|
||||
console.log(" bunx fff download abc1234 Download specific commit hash");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Postinstall script - automatically downloads the native binary
|
||||
*/
|
||||
|
||||
import { downloadBinary, findBinary, getInstalledHash } from "../src/download";
|
||||
|
||||
async function main() {
|
||||
// Check if binary already exists (dev build or previous download)
|
||||
const existing = findBinary();
|
||||
if (existing) {
|
||||
const hash = await getInstalledHash();
|
||||
console.log(`fff: Native library found at ${existing}`);
|
||||
if (hash) {
|
||||
console.log(`fff: Version: ${hash}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("fff: Native library not found, downloading...");
|
||||
|
||||
try {
|
||||
const hash = await downloadBinary();
|
||||
console.log(`fff: Native library installed successfully! (${hash})`);
|
||||
} catch (error) {
|
||||
console.error("fff: Failed to download native library:", error);
|
||||
console.error("");
|
||||
console.error("fff: You can build from source instead:");
|
||||
console.error(" cd node_modules/fff && cargo build --release -p fff-c");
|
||||
console.error("");
|
||||
console.error("fff: Or run `bunx fff download` after fixing network issues.");
|
||||
// Don't exit with error - allow install to complete
|
||||
// The error will surface when the user tries to use the library
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Binary download utilities for fff
|
||||
*
|
||||
* Downloads prebuilt binaries from GitHub releases based on commit hash.
|
||||
* The release tag corresponds to the short commit SHA (7 characters).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { getTriple, getLibExtension, getLibFilename } from "./platform";
|
||||
|
||||
const GITHUB_REPO = "dmtrKovalenko/fff.nvim";
|
||||
const GITHUB_API = "https://api.github.com";
|
||||
|
||||
/**
|
||||
* Get the current file's directory
|
||||
*/
|
||||
function getCurrentDir(): string {
|
||||
const url = import.meta.url;
|
||||
if (url.startsWith("file://")) {
|
||||
return dirname(fileURLToPath(url));
|
||||
}
|
||||
return dirname(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the package root directory
|
||||
*/
|
||||
function getPackageDir(): string {
|
||||
const currentDir = getCurrentDir();
|
||||
return dirname(currentDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to package.json
|
||||
*/
|
||||
function getPackageJsonPath(): string {
|
||||
return join(getPackageDir(), "package.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory where binaries are stored
|
||||
*/
|
||||
export function getBinDir(): string {
|
||||
return join(getPackageDir(), "bin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to the native library
|
||||
*/
|
||||
export function getBinaryPath(): string {
|
||||
const binDir = getBinDir();
|
||||
return join(binDir, getLibFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the binary exists
|
||||
*/
|
||||
export function binaryExists(): boolean {
|
||||
return existsSync(getBinaryPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package.json
|
||||
*/
|
||||
async function readPackageJson(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await Bun.file(getPackageJsonPath()).json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write package.json
|
||||
*/
|
||||
async function writePackageJson(pkg: Record<string, unknown>): Promise<void> {
|
||||
const content = JSON.stringify(pkg, null, 2) + "\n";
|
||||
writeFileSync(getPackageJsonPath(), content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the installed binary hash from package.json
|
||||
*/
|
||||
export async function getInstalledHash(): Promise<string | null> {
|
||||
const pkg = await readPackageJson();
|
||||
return (pkg.nativeBinaryHash as string) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the installed hash in package.json
|
||||
*/
|
||||
async function setInstalledHash(hash: string): Promise<void> {
|
||||
const pkg = await readPackageJson();
|
||||
pkg.nativeBinaryHash = hash;
|
||||
await writePackageJson(pkg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the development binary path (for local development)
|
||||
*/
|
||||
export function getDevBinaryPath(): string | null {
|
||||
const packageDir = getPackageDir();
|
||||
const workspaceRoot = join(packageDir, "..", "..");
|
||||
|
||||
const possiblePaths = [
|
||||
join(workspaceRoot, "target", "release", getLibFilename()),
|
||||
join(workspaceRoot, "target", "debug", getLibFilename()),
|
||||
];
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
if (existsSync(path)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the binary, checking both installed and dev paths
|
||||
*/
|
||||
export function findBinary(): string | null {
|
||||
const installedPath = getBinaryPath();
|
||||
if (existsSync(installedPath)) {
|
||||
return installedPath;
|
||||
}
|
||||
return getDevBinaryPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release tag from GitHub
|
||||
*/
|
||||
async function fetchLatestReleaseTag(): Promise<string> {
|
||||
const url = `${GITHUB_API}/repos/${GITHUB_REPO}/releases/latest`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "fff-bun-client",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If no "latest" release, try getting the most recent prerelease
|
||||
const allReleasesUrl = `${GITHUB_API}/repos/${GITHUB_REPO}/releases`;
|
||||
const allResponse = await fetch(allReleasesUrl, {
|
||||
headers: {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "fff-bun-client",
|
||||
},
|
||||
});
|
||||
|
||||
if (!allResponse.ok) {
|
||||
throw new Error(`Failed to fetch releases: ${allResponse.status}`);
|
||||
}
|
||||
|
||||
const releases = await allResponse.json() as Array<{ tag_name: string }>;
|
||||
if (releases.length === 0) {
|
||||
throw new Error("No releases found");
|
||||
}
|
||||
|
||||
return releases[0].tag_name;
|
||||
}
|
||||
|
||||
const release = await response.json() as { tag_name: string };
|
||||
return release.tag_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the hash to use for downloading
|
||||
* If "latest", fetches the latest release tag from GitHub
|
||||
*/
|
||||
async function resolveHash(hash: string): Promise<string> {
|
||||
if (hash === "latest") {
|
||||
console.log("fff: Fetching latest release tag...");
|
||||
return await fetchLatestReleaseTag();
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and verify checksum for a binary
|
||||
*/
|
||||
async function downloadWithChecksum(
|
||||
binaryUrl: string,
|
||||
checksumUrl: string,
|
||||
): Promise<Buffer> {
|
||||
// Download binary
|
||||
const binaryResponse = await fetch(binaryUrl);
|
||||
if (!binaryResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to download binary: ${binaryResponse.status} ${binaryResponse.statusText}\nURL: ${binaryUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
const binaryBuffer = Buffer.from(await binaryResponse.arrayBuffer());
|
||||
|
||||
// Try to download and verify checksum
|
||||
try {
|
||||
const checksumResponse = await fetch(checksumUrl);
|
||||
if (checksumResponse.ok) {
|
||||
const checksumText = await checksumResponse.text();
|
||||
// Format: "hash filename" or just "hash"
|
||||
const expectedHash = checksumText.trim().split(/\s+/)[0];
|
||||
|
||||
const actualHash = createHash("sha256").update(binaryBuffer).digest("hex");
|
||||
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new Error(
|
||||
`Checksum mismatch!\nExpected: ${expectedHash}\nActual: ${actualHash}`,
|
||||
);
|
||||
}
|
||||
console.log("fff: Checksum verified ✓");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Checksum mismatch")) {
|
||||
throw error;
|
||||
}
|
||||
// Checksum file not found, continue without verification
|
||||
console.log("fff: Checksum file not available, skipping verification");
|
||||
}
|
||||
|
||||
return binaryBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the binary from GitHub releases
|
||||
* @param hash - The commit hash (release tag) to download, or "latest"
|
||||
*/
|
||||
export async function downloadBinary(hash?: string): Promise<string> {
|
||||
const currentHash = await getInstalledHash();
|
||||
const packageHash = hash || currentHash || "latest";
|
||||
const resolvedHash = await resolveHash(packageHash);
|
||||
|
||||
const triple = getTriple();
|
||||
const ext = getLibExtension();
|
||||
|
||||
// Binary name format: c-lib-{triple}.{ext}
|
||||
const binaryName = `c-lib-${triple}.${ext}`;
|
||||
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${resolvedHash}`;
|
||||
const binaryUrl = `${baseUrl}/${binaryName}`;
|
||||
const checksumUrl = `${baseUrl}/${binaryName}.sha256`;
|
||||
|
||||
console.log(`fff: Downloading native library for ${triple}...`);
|
||||
console.log(`fff: Release: ${resolvedHash}`);
|
||||
console.log(`fff: URL: ${binaryUrl}`);
|
||||
|
||||
const binaryBuffer = await downloadWithChecksum(binaryUrl, checksumUrl);
|
||||
|
||||
const binDir = getBinDir();
|
||||
if (!existsSync(binDir)) {
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
}
|
||||
|
||||
const binaryPath = getBinaryPath();
|
||||
writeFileSync(binaryPath, binaryBuffer);
|
||||
|
||||
// Save the hash to package.json
|
||||
await setInstalledHash(resolvedHash);
|
||||
|
||||
// Make executable on Unix
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
console.log(`fff: Binary downloaded to ${binaryPath}`);
|
||||
return resolvedHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is available
|
||||
*/
|
||||
export async function checkForUpdate(): Promise<{
|
||||
currentHash: string | null;
|
||||
latestHash: string;
|
||||
updateAvailable: boolean;
|
||||
}> {
|
||||
const currentHash = await getInstalledHash();
|
||||
const latestHash = await fetchLatestReleaseTag();
|
||||
|
||||
return {
|
||||
currentHash,
|
||||
latestHash,
|
||||
updateAvailable: currentHash !== latestHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the binary exists, downloading if necessary
|
||||
*/
|
||||
export async function ensureBinary(): Promise<string> {
|
||||
const existingPath = findBinary();
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
await downloadBinary();
|
||||
return getBinaryPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download binary, with fallback to cargo build instructions
|
||||
*/
|
||||
export async function downloadOrBuild(): Promise<void> {
|
||||
try {
|
||||
await downloadBinary();
|
||||
} catch (error) {
|
||||
console.error(`fff: Failed to download binary: ${error}`);
|
||||
console.error(`fff: You can build from source instead:`);
|
||||
console.error(` cargo build --release -p fff-c`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Bun FFI bindings for the fff-c native library
|
||||
*
|
||||
* This module uses Bun's native FFI to call into the Rust C library.
|
||||
* All functions follow the Result pattern for error handling.
|
||||
*/
|
||||
|
||||
import { dlopen, FFIType, ptr, CString, read, type Pointer } from "bun:ffi";
|
||||
import { findBinary, ensureBinary } from "./download";
|
||||
import type { Result } from "./types";
|
||||
import { err } from "./types";
|
||||
|
||||
// Define the FFI symbols
|
||||
const ffiDefinition = {
|
||||
// Lifecycle
|
||||
fff_init: {
|
||||
args: [FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_destroy: {
|
||||
args: [],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Search
|
||||
fff_search: {
|
||||
args: [FFIType.cstring, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// File index
|
||||
fff_scan_files: {
|
||||
args: [],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_is_scanning: {
|
||||
args: [],
|
||||
returns: FFIType.bool,
|
||||
},
|
||||
fff_get_scan_progress: {
|
||||
args: [],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_wait_for_scan: {
|
||||
args: [FFIType.u64],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_restart_index: {
|
||||
args: [FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Frecency
|
||||
fff_track_access: {
|
||||
args: [FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Git
|
||||
fff_refresh_git_status: {
|
||||
args: [],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Query tracking
|
||||
fff_track_query: {
|
||||
args: [FFIType.cstring, FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
fff_get_historical_query: {
|
||||
args: [FFIType.u64],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Utilities
|
||||
fff_health_check: {
|
||||
args: [FFIType.cstring],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
|
||||
// Memory management
|
||||
fff_free_result: {
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
fff_free_string: {
|
||||
args: [FFIType.ptr],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type FFFLibrary = ReturnType<typeof dlopen<typeof ffiDefinition>>;
|
||||
|
||||
// Library instance (lazy loaded)
|
||||
let lib: FFFLibrary | null = null;
|
||||
|
||||
/**
|
||||
* Load the native library
|
||||
*/
|
||||
function loadLibrary(): FFFLibrary {
|
||||
if (lib) return lib;
|
||||
|
||||
const binaryPath = findBinary();
|
||||
if (!binaryPath) {
|
||||
throw new Error(
|
||||
"fff native library not found. Run `bunx fff download` or build from source with `cargo build --release -p fff-c`"
|
||||
);
|
||||
}
|
||||
|
||||
lib = dlopen(binaryPath, ffiDefinition);
|
||||
return lib;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a string for FFI (null-terminated)
|
||||
*/
|
||||
function encodeString(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s + "\0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a C string from a pointer
|
||||
* Note: read.ptr() returns number but CString expects Pointer - we cast through unknown
|
||||
*/
|
||||
function readCString(pointer: Pointer | number | null): string | null {
|
||||
if (pointer === null || pointer === 0) return null;
|
||||
// CString constructor accepts Pointer, but read.ptr returns number
|
||||
// Cast through unknown for runtime compatibility
|
||||
return new CString(pointer as unknown as Pointer).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert snake_case keys to camelCase recursively
|
||||
*/
|
||||
function snakeToCamel(obj: unknown): unknown {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (typeof obj !== "object") return obj;
|
||||
if (Array.isArray(obj)) return obj.map(snakeToCamel);
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
|
||||
letter.toUpperCase()
|
||||
);
|
||||
result[camelKey] = snakeToCamel(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a FffResult from the FFI return value
|
||||
* The result is a pointer to a struct: { success: bool, data: *char, error: *char }
|
||||
*/
|
||||
function parseResult<T>(resultPtr: Pointer | null): Result<T> {
|
||||
if (resultPtr === null) {
|
||||
return err("FFI returned null pointer");
|
||||
}
|
||||
|
||||
// Read the struct fields
|
||||
// FffResult layout: bool (1 byte + 7 padding) + pointer (8 bytes) + pointer (8 bytes)
|
||||
// offset 0: success (bool, 1 byte)
|
||||
// offset 8: data pointer (8 bytes)
|
||||
// offset 16: error pointer (8 bytes)
|
||||
const success = read.u8(resultPtr, 0) !== 0;
|
||||
const dataPtr = read.ptr(resultPtr, 8);
|
||||
const errorPtr = read.ptr(resultPtr, 16);
|
||||
|
||||
const library = loadLibrary();
|
||||
|
||||
if (success) {
|
||||
const data = readCString(dataPtr);
|
||||
// Free the result
|
||||
library.symbols.fff_free_result(resultPtr);
|
||||
|
||||
if (data === null || data === "") {
|
||||
return { ok: true, value: undefined as T };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
// Convert snake_case to camelCase for TypeScript consumers
|
||||
const transformed = snakeToCamel(parsed) as T;
|
||||
return { ok: true, value: transformed };
|
||||
} catch {
|
||||
// For simple values like "true" or numbers
|
||||
return { ok: true, value: data as T };
|
||||
}
|
||||
} else {
|
||||
const errorMsg = readCString(errorPtr) || "Unknown error";
|
||||
// Free the result
|
||||
library.symbols.fff_free_result(resultPtr);
|
||||
return err(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the file finder
|
||||
*/
|
||||
export function ffiInit(optsJson: string): Result<void> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_init(ptr(encodeString(optsJson)));
|
||||
return parseResult<void>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy and clean up resources
|
||||
*/
|
||||
export function ffiDestroy(): Result<void> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_destroy();
|
||||
return parseResult<void>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform fuzzy search
|
||||
*/
|
||||
export function ffiSearch(query: string, optsJson: string): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_search(
|
||||
ptr(encodeString(query)),
|
||||
ptr(encodeString(optsJson))
|
||||
);
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger file scan
|
||||
*/
|
||||
export function ffiScanFiles(): Result<void> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_scan_files();
|
||||
return parseResult<void>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scanning
|
||||
*/
|
||||
export function ffiIsScanning(): boolean {
|
||||
const library = loadLibrary();
|
||||
return library.symbols.fff_is_scanning() as boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan progress
|
||||
*/
|
||||
export function ffiGetScanProgress(): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_get_scan_progress();
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for scan to complete
|
||||
*/
|
||||
export function ffiWaitForScan(timeoutMs: number): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_wait_for_scan(BigInt(timeoutMs));
|
||||
const result = parseResult<string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: result.value === "true" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart index in new path
|
||||
*/
|
||||
export function ffiRestartIndex(newPath: string): Result<void> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_restart_index(
|
||||
ptr(encodeString(newPath))
|
||||
);
|
||||
return parseResult<void>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track file access
|
||||
*/
|
||||
export function ffiTrackAccess(filePath: string): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_track_access(
|
||||
ptr(encodeString(filePath))
|
||||
);
|
||||
const result = parseResult<string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: result.value === "true" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh git status
|
||||
*/
|
||||
export function ffiRefreshGitStatus(): Result<number> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_refresh_git_status();
|
||||
const result = parseResult<string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: parseInt(result.value, 10) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Track query completion
|
||||
*/
|
||||
export function ffiTrackQuery(
|
||||
query: string,
|
||||
filePath: string
|
||||
): Result<boolean> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_track_query(
|
||||
ptr(encodeString(query)),
|
||||
ptr(encodeString(filePath))
|
||||
);
|
||||
const result = parseResult<string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
return { ok: true, value: result.value === "true" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical query
|
||||
*/
|
||||
export function ffiGetHistoricalQuery(offset: number): Result<string | null> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_get_historical_query(BigInt(offset));
|
||||
const result = parseResult<string>(resultPtr);
|
||||
if (!result.ok) return result;
|
||||
if (result.value === "null") return { ok: true, value: null };
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
export function ffiHealthCheck(testPath: string): Result<unknown> {
|
||||
const library = loadLibrary();
|
||||
const resultPtr = library.symbols.fff_health_check(
|
||||
ptr(encodeString(testPath))
|
||||
);
|
||||
return parseResult<unknown>(resultPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the library is loaded (for preloading)
|
||||
*/
|
||||
export async function ensureLoaded(): Promise<void> {
|
||||
await ensureBinary();
|
||||
loadLibrary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the library is available
|
||||
*/
|
||||
export function isAvailable(): boolean {
|
||||
try {
|
||||
loadLibrary();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* FileFinder - High-level API for the fff file finder
|
||||
*
|
||||
* This class provides a type-safe, ergonomic API for file finding operations.
|
||||
* All methods return Result types for explicit error handling.
|
||||
*/
|
||||
|
||||
import {
|
||||
ffiInit,
|
||||
ffiDestroy,
|
||||
ffiSearch,
|
||||
ffiScanFiles,
|
||||
ffiIsScanning,
|
||||
ffiGetScanProgress,
|
||||
ffiWaitForScan,
|
||||
ffiRestartIndex,
|
||||
ffiTrackAccess,
|
||||
ffiRefreshGitStatus,
|
||||
ffiTrackQuery,
|
||||
ffiGetHistoricalQuery,
|
||||
ffiHealthCheck,
|
||||
ensureLoaded,
|
||||
isAvailable,
|
||||
} from "./ffi";
|
||||
|
||||
import type {
|
||||
Result,
|
||||
InitOptions,
|
||||
SearchOptions,
|
||||
SearchResult,
|
||||
ScanProgress,
|
||||
HealthCheck,
|
||||
} from "./types";
|
||||
|
||||
import { err, toInternalInitOptions, toInternalSearchOptions } from "./types";
|
||||
|
||||
/**
|
||||
* FileFinder - Fast file finder with fuzzy search
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { FileFinder } from "fff";
|
||||
*
|
||||
* // Initialize
|
||||
* const result = FileFinder.init({ basePath: "/path/to/project" });
|
||||
* if (!result.ok) {
|
||||
* console.error(result.error);
|
||||
* process.exit(1);
|
||||
* }
|
||||
*
|
||||
* // Wait for initial scan
|
||||
* FileFinder.waitForScan(5000);
|
||||
*
|
||||
* // Search for files
|
||||
* const search = FileFinder.search("main.ts");
|
||||
* if (search.ok) {
|
||||
* for (const item of search.value.items) {
|
||||
* console.log(item.relativePath);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Cleanup
|
||||
* FileFinder.destroy();
|
||||
* ```
|
||||
*/
|
||||
export class FileFinder {
|
||||
private static initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the file finder with the given options.
|
||||
*
|
||||
* @param options - Initialization options
|
||||
* @returns Result indicating success or failure
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic initialization
|
||||
* FileFinder.init({ basePath: "/path/to/project" });
|
||||
*
|
||||
* // With custom database paths
|
||||
* FileFinder.init({
|
||||
* basePath: "/path/to/project",
|
||||
* frecencyDbPath: "/custom/frecency.mdb",
|
||||
* historyDbPath: "/custom/history.mdb",
|
||||
* });
|
||||
*
|
||||
* // Minimal mode (no databases - just omit db paths)
|
||||
* FileFinder.init({ basePath: "/path/to/project" });
|
||||
* ```
|
||||
*/
|
||||
static init(options: InitOptions): Result<void> {
|
||||
const internalOpts = toInternalInitOptions(options);
|
||||
const result = ffiInit(JSON.stringify(internalOpts));
|
||||
|
||||
if (result.ok) {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy and clean up all resources.
|
||||
*
|
||||
* Call this when you're done using the file finder to free memory
|
||||
* and stop background file watching.
|
||||
*/
|
||||
static destroy(): Result<void> {
|
||||
const result = ffiDestroy();
|
||||
if (result.ok) {
|
||||
this.initialized = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for files matching the query.
|
||||
*
|
||||
* The query supports fuzzy matching and special syntax:
|
||||
* - `foo bar` - Match files containing "foo" and "bar"
|
||||
* - `src/` - Match files in src directory
|
||||
* - `file.ts:42` - Match file.ts with line 42
|
||||
* - `file.ts:42:10` - Match file.ts with line 42, column 10
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param options - Search options
|
||||
* @returns Search results with matched files and scores
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = FileFinder.search("main.ts", { pageSize: 10 });
|
||||
* if (result.ok) {
|
||||
* console.log(`Found ${result.value.totalMatched} files`);
|
||||
* for (const item of result.value.items) {
|
||||
* console.log(item.relativePath);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static search(query: string, options?: SearchOptions): Result<SearchResult> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
|
||||
const internalOpts = toInternalSearchOptions(options);
|
||||
const result = ffiSearch(query, JSON.stringify(internalOpts));
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// The FFI returns the search result already parsed
|
||||
return result as Result<SearchResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a rescan of the indexed directory.
|
||||
*
|
||||
* This is useful after major file system changes that the
|
||||
* background watcher might have missed.
|
||||
*/
|
||||
static scanFiles(): Result<void> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiScanFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a scan is currently in progress.
|
||||
*/
|
||||
static isScanning(): boolean {
|
||||
if (!this.initialized) return false;
|
||||
return ffiIsScanning();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current scan progress.
|
||||
*/
|
||||
static getScanProgress(): Result<ScanProgress> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiGetScanProgress() as Result<ScanProgress>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the initial file scan to complete.
|
||||
*
|
||||
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
|
||||
* @returns true if scan completed, false if timed out
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* FileFinder.init({ basePath: "/path/to/project" });
|
||||
* const completed = FileFinder.waitForScan(10000);
|
||||
* if (!completed.ok || !completed.value) {
|
||||
* console.warn("Scan did not complete in time");
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static waitForScan(timeoutMs: number = 5000): Result<boolean> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiWaitForScan(timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the indexed directory to a new path.
|
||||
*
|
||||
* This stops the current file watcher and starts indexing the new directory.
|
||||
*
|
||||
* @param newPath - New directory path to index
|
||||
*/
|
||||
static reindex(newPath: string): Result<void> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiRestartIndex(newPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track file access for frecency scoring.
|
||||
*
|
||||
* Call this when a user opens a file to improve future search rankings.
|
||||
*
|
||||
* @param filePath - Absolute path to the accessed file
|
||||
*/
|
||||
static trackAccess(filePath: string): Result<boolean> {
|
||||
if (!this.initialized) {
|
||||
return { ok: true, value: false };
|
||||
}
|
||||
return ffiTrackAccess(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the git status cache.
|
||||
*
|
||||
* @returns Number of files with updated git status
|
||||
*/
|
||||
static refreshGitStatus(): Result<number> {
|
||||
if (!this.initialized) {
|
||||
return err("FileFinder not initialized. Call FileFinder.init() first.");
|
||||
}
|
||||
return ffiRefreshGitStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Track query completion for smart suggestions.
|
||||
*
|
||||
* Call this when a user selects a file from search results.
|
||||
* This helps improve future search rankings for similar queries.
|
||||
*
|
||||
* @param query - The search query that was used
|
||||
* @param selectedFilePath - The file path that was selected
|
||||
*/
|
||||
static trackQuery(query: string, selectedFilePath: string): Result<boolean> {
|
||||
if (!this.initialized) {
|
||||
return { ok: true, value: false };
|
||||
}
|
||||
return ffiTrackQuery(query, selectedFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a historical query by offset.
|
||||
*
|
||||
* @param offset - Offset from most recent (0 = most recent)
|
||||
* @returns The historical query string, or null if not found
|
||||
*/
|
||||
static getHistoricalQuery(offset: number): Result<string | null> {
|
||||
if (!this.initialized) {
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
return ffiGetHistoricalQuery(offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health check information.
|
||||
*
|
||||
* Useful for debugging and verifying the file finder is working correctly.
|
||||
*
|
||||
* @param testPath - Optional path to test git repository detection
|
||||
*/
|
||||
static healthCheck(testPath?: string): Result<HealthCheck> {
|
||||
return ffiHealthCheck(testPath || "") as Result<HealthCheck>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the native library is available.
|
||||
*/
|
||||
static isAvailable(): boolean {
|
||||
return isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the native library is loaded.
|
||||
*
|
||||
* This will download the binary if needed and load it.
|
||||
* Useful for preloading before first use.
|
||||
*/
|
||||
static async ensureLoaded(): Promise<void> {
|
||||
return ensureLoaded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the file finder is initialized.
|
||||
*/
|
||||
static isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { FileFinder } from "./index";
|
||||
import { findBinary, getDevBinaryPath } from "./download";
|
||||
import { getTriple, getLibExtension, getLibFilename } from "./platform";
|
||||
|
||||
const testDir = process.cwd();
|
||||
|
||||
describe("Platform Detection", () => {
|
||||
test("getTriple returns valid triple", () => {
|
||||
const triple = getTriple();
|
||||
expect(triple).toMatch(
|
||||
/^(x86_64|aarch64|arm)-(apple-darwin|unknown-linux-(gnu|musl)|pc-windows-msvc)$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("getLibExtension returns correct extension", () => {
|
||||
const ext = getLibExtension();
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === "darwin") {
|
||||
expect(ext).toBe("dylib");
|
||||
} else if (platform === "win32") {
|
||||
expect(ext).toBe("dll");
|
||||
} else {
|
||||
expect(ext).toBe("so");
|
||||
}
|
||||
});
|
||||
|
||||
test("getLibFilename returns correct filename", () => {
|
||||
const filename = getLibFilename();
|
||||
const ext = getLibExtension();
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(filename).toBe(`fff_c.${ext}`);
|
||||
} else {
|
||||
expect(filename).toBe(`libfff_c.${ext}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Binary Detection", () => {
|
||||
test("getDevBinaryPath finds local build", () => {
|
||||
const devPath = getDevBinaryPath();
|
||||
expect(devPath).not.toBeNull();
|
||||
expect(devPath).toContain("target/release");
|
||||
});
|
||||
|
||||
test("findBinary returns a path", () => {
|
||||
const path = findBinary();
|
||||
expect(path).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileFinder - Health Check", () => {
|
||||
test("healthCheck works before initialization", () => {
|
||||
// Make sure we start fresh
|
||||
FileFinder.destroy();
|
||||
|
||||
const result = FileFinder.healthCheck();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.value.version).toBeDefined();
|
||||
expect(result.value.git.available).toBe(true);
|
||||
expect(result.value.filePicker.initialized).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileFinder - Full Lifecycle", () => {
|
||||
// Single beforeAll/afterAll for the entire test suite to avoid repeated init/destroy
|
||||
beforeAll(() => {
|
||||
FileFinder.destroy(); // Clean any previous state
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
FileFinder.destroy();
|
||||
});
|
||||
|
||||
test("init succeeds with valid path", () => {
|
||||
const result = FileFinder.init({
|
||||
basePath: testDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(FileFinder.isInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
test("isScanning returns a boolean", () => {
|
||||
const scanning = FileFinder.isScanning();
|
||||
expect(typeof scanning).toBe("boolean");
|
||||
});
|
||||
|
||||
test("getScanProgress returns valid data", () => {
|
||||
const result = FileFinder.getScanProgress();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(typeof result.value.scannedFilesCount).toBe("number");
|
||||
expect(typeof result.value.isScanning).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
test("waitForScan completes", () => {
|
||||
// Small timeout - scan should be fast or already done
|
||||
const result = FileFinder.waitForScan(500);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("search with empty query returns all files", () => {
|
||||
const result = FileFinder.search("");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
// Empty query should return files (frecency-sorted)
|
||||
expect(result.value.totalFiles).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("search returns a valid result structure", () => {
|
||||
const result = FileFinder.search("Cargo.toml");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(typeof result.value.totalMatched).toBe("number");
|
||||
expect(typeof result.value.totalFiles).toBe("number");
|
||||
expect(Array.isArray(result.value.items)).toBe(true);
|
||||
expect(Array.isArray(result.value.scores)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("search returns empty for non-matching query", () => {
|
||||
const result = FileFinder.search("xyznonexistentfilenamexyz123456");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.value.totalMatched).toBe(0);
|
||||
expect(result.value.items.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("search respects pageSize option", () => {
|
||||
const result = FileFinder.search("ts", { pageSize: 3 });
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.value.items.length).toBeLessThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
test("healthCheck shows initialized state", () => {
|
||||
const result = FileFinder.healthCheck();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.value.filePicker.initialized).toBe(true);
|
||||
expect(result.value.filePicker.basePath).toBeDefined();
|
||||
expect(typeof result.value.filePicker.indexedFiles).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("healthCheck detects git repository", () => {
|
||||
const result = FileFinder.healthCheck(testDir);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.value.git.available).toBe(true);
|
||||
expect(typeof result.value.git.repositoryFound).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy and re-init works", () => {
|
||||
FileFinder.destroy();
|
||||
expect(FileFinder.isInitialized()).toBe(false);
|
||||
|
||||
const result = FileFinder.init({
|
||||
basePath: testDir,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(FileFinder.isInitialized()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileFinder - Error Handling", () => {
|
||||
test("search fails when not initialized", () => {
|
||||
FileFinder.destroy();
|
||||
|
||||
const result = FileFinder.search("test");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("not initialized");
|
||||
}
|
||||
});
|
||||
|
||||
test("getScanProgress fails when not initialized", () => {
|
||||
const result = FileFinder.getScanProgress();
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("init fails with invalid path", () => {
|
||||
const result = FileFinder.init({
|
||||
basePath: "/nonexistent/path/that/does/not/exist",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("Failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Result Type Helpers", () => {
|
||||
test("ok helper creates success result", async () => {
|
||||
const { ok } = await import("./types");
|
||||
const result = ok(42);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(42);
|
||||
}
|
||||
});
|
||||
|
||||
test("err helper creates error result", async () => {
|
||||
const { err } = await import("./types");
|
||||
const result = err<number>("something went wrong");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe("something went wrong");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* fff - Fast File Finder
|
||||
*
|
||||
* High-performance fuzzy file finder for Bun, powered by Rust.
|
||||
* Perfect for LLM agent tools that need to search through codebases.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { FileFinder } from "fff";
|
||||
*
|
||||
* // Initialize with a directory
|
||||
* const result = FileFinder.init({ basePath: "/path/to/project" });
|
||||
* if (!result.ok) {
|
||||
* console.error(result.error);
|
||||
* process.exit(1);
|
||||
* }
|
||||
*
|
||||
* // Wait for initial scan
|
||||
* FileFinder.waitForScan(5000);
|
||||
*
|
||||
* // Search for files
|
||||
* const search = FileFinder.search("main.ts");
|
||||
* if (search.ok) {
|
||||
* for (const item of search.value.items) {
|
||||
* console.log(item.relativePath);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Track file access (for frecency)
|
||||
* FileFinder.trackAccess("/path/to/project/src/main.ts");
|
||||
*
|
||||
* // Cleanup when done
|
||||
* FileFinder.destroy();
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// Main API
|
||||
export { FileFinder } from "./finder";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Result,
|
||||
InitOptions,
|
||||
SearchOptions,
|
||||
FileItem,
|
||||
Score,
|
||||
Location,
|
||||
SearchResult,
|
||||
ScanProgress,
|
||||
HealthCheck,
|
||||
DbHealth,
|
||||
} from "./types";
|
||||
|
||||
// Result helpers
|
||||
export { ok, err } from "./types";
|
||||
|
||||
// Binary management (for CLI tools)
|
||||
export {
|
||||
downloadBinary,
|
||||
ensureBinary,
|
||||
binaryExists,
|
||||
getBinaryPath,
|
||||
findBinary,
|
||||
} from "./download";
|
||||
|
||||
// Platform utilities
|
||||
export { getTriple, getLibExtension, getLibFilename } from "./platform";
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Platform detection utilities for downloading the correct binary
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Get the platform triple (e.g., "x86_64-unknown-linux-gnu")
|
||||
*/
|
||||
export function getTriple(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
let osName: string;
|
||||
if (platform === "darwin") {
|
||||
osName = "apple-darwin";
|
||||
} else if (platform === "linux") {
|
||||
osName = detectLinuxLibc();
|
||||
} else if (platform === "win32") {
|
||||
osName = "pc-windows-msvc";
|
||||
} else {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
const archName = normalizeArch(arch);
|
||||
return `${archName}-${osName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether we're on musl or glibc Linux
|
||||
*/
|
||||
function detectLinuxLibc(): string {
|
||||
try {
|
||||
const lddOutput = execSync("ldd --version 2>&1", {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
});
|
||||
if (lddOutput.toLowerCase().includes("musl")) {
|
||||
return "unknown-linux-musl";
|
||||
}
|
||||
} catch {
|
||||
// ldd failed, assume glibc
|
||||
}
|
||||
return "unknown-linux-gnu";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize architecture name to Rust target format
|
||||
*/
|
||||
function normalizeArch(arch: string): string {
|
||||
switch (arch) {
|
||||
case "x64":
|
||||
case "amd64":
|
||||
return "x86_64";
|
||||
case "arm64":
|
||||
return "aarch64";
|
||||
case "arm":
|
||||
return "arm";
|
||||
default:
|
||||
throw new Error(`Unsupported architecture: ${arch}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the library file extension for the current platform
|
||||
*/
|
||||
export function getLibExtension(): "dylib" | "so" | "dll" {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "dylib";
|
||||
case "win32":
|
||||
return "dll";
|
||||
default:
|
||||
return "so";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the library filename prefix (empty on Windows)
|
||||
*/
|
||||
export function getLibPrefix(): string {
|
||||
return process.platform === "win32" ? "" : "lib";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full library filename for the current platform
|
||||
*/
|
||||
export function getLibFilename(): string {
|
||||
const prefix = getLibPrefix();
|
||||
const ext = getLibExtension();
|
||||
return `${prefix}fff_c.${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Result type for all operations - follows the Result pattern
|
||||
*/
|
||||
export type Result<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Helper to create a successful result
|
||||
*/
|
||||
export function ok<T>(value: T): Result<T> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an error result
|
||||
*/
|
||||
export function err<T>(error: string): Result<T> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialization options for the file finder
|
||||
*/
|
||||
export interface InitOptions {
|
||||
/** Base directory to index (required) */
|
||||
basePath: string;
|
||||
/** Path to frecency database (optional, omit to skip frecency initialization) */
|
||||
frecencyDbPath?: string;
|
||||
/** Path to query history database (optional, omit to skip query tracker initialization) */
|
||||
historyDbPath?: string;
|
||||
/** Use unsafe no-lock mode for databases (optional, defaults to false) */
|
||||
useUnsafeNoLock?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search options for fuzzy file search
|
||||
*/
|
||||
export interface SearchOptions {
|
||||
/** Maximum threads for parallel search (0 = auto) */
|
||||
maxThreads?: number;
|
||||
/** Current file path (for deprioritization in results) */
|
||||
currentFile?: string;
|
||||
/** Combo boost score multiplier (default: 100) */
|
||||
comboBoostMultiplier?: number;
|
||||
/** Minimum combo count for boost (default: 3) */
|
||||
minComboCount?: number;
|
||||
/** Page index for pagination (default: 0) */
|
||||
pageIndex?: number;
|
||||
/** Page size for pagination (default: 100) */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file item in search results
|
||||
*/
|
||||
export interface FileItem {
|
||||
/** Absolute path to the file */
|
||||
path: string;
|
||||
/** Path relative to the indexed directory */
|
||||
relativePath: string;
|
||||
/** File name only */
|
||||
fileName: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Last modified timestamp (Unix seconds) */
|
||||
modified: number;
|
||||
/** Frecency score based on access patterns */
|
||||
accessFrecencyScore: number;
|
||||
/** Frecency score based on modification time */
|
||||
modificationFrecencyScore: number;
|
||||
/** Combined frecency score */
|
||||
totalFrecencyScore: number;
|
||||
/** Git status: 'clean', 'modified', 'untracked', 'staged_new', etc. */
|
||||
gitStatus: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score breakdown for a search result
|
||||
*/
|
||||
export interface Score {
|
||||
/** Total combined score */
|
||||
total: number;
|
||||
/** Base fuzzy match score */
|
||||
baseScore: number;
|
||||
/** Bonus for filename match */
|
||||
filenameBonus: number;
|
||||
/** Bonus for special filenames (index.ts, main.rs, etc.) */
|
||||
specialFilenameBonus: number;
|
||||
/** Boost from frecency */
|
||||
frecencyBoost: number;
|
||||
/** Penalty for distance in path */
|
||||
distancePenalty: number;
|
||||
/** Penalty if this is the current file */
|
||||
currentFilePenalty: number;
|
||||
/** Boost from query history combo matching */
|
||||
comboMatchBoost: number;
|
||||
/** Whether this was an exact match */
|
||||
exactMatch: boolean;
|
||||
/** Type of match: 'fuzzy', 'exact', 'prefix', etc. */
|
||||
matchType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Location in file (from query like "file.ts:42")
|
||||
*/
|
||||
export type Location =
|
||||
| { type: "line"; line: number }
|
||||
| { type: "position"; line: number; col: number }
|
||||
| {
|
||||
type: "range";
|
||||
start: { line: number; col: number };
|
||||
end: { line: number; col: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Search result from fuzzy file search
|
||||
*/
|
||||
export interface SearchResult {
|
||||
/** Matched file items */
|
||||
items: FileItem[];
|
||||
/** Corresponding scores for each item */
|
||||
scores: Score[];
|
||||
/** Total number of files that matched */
|
||||
totalMatched: number;
|
||||
/** Total number of indexed files */
|
||||
totalFiles: number;
|
||||
/** Location parsed from query (e.g., "file.ts:42:10") */
|
||||
location?: Location;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan progress information
|
||||
*/
|
||||
export interface ScanProgress {
|
||||
/** Number of files scanned so far */
|
||||
scannedFilesCount: number;
|
||||
/** Whether a scan is currently in progress */
|
||||
isScanning: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database health information
|
||||
*/
|
||||
export interface DbHealth {
|
||||
/** Path to the database */
|
||||
path: string;
|
||||
/** Size of the database on disk in bytes */
|
||||
diskSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check result
|
||||
*/
|
||||
export interface HealthCheck {
|
||||
/** Library version */
|
||||
version: string;
|
||||
/** Git integration status */
|
||||
git: {
|
||||
/** Whether git2 library is available */
|
||||
available: boolean;
|
||||
/** Whether a git repository was found */
|
||||
repositoryFound: boolean;
|
||||
/** Git working directory path */
|
||||
workdir?: string;
|
||||
/** libgit2 version string */
|
||||
libgit2Version: string;
|
||||
/** Error message if git detection failed */
|
||||
error?: string;
|
||||
};
|
||||
/** File picker status */
|
||||
filePicker: {
|
||||
/** Whether the file picker is initialized */
|
||||
initialized: boolean;
|
||||
/** Base path being indexed */
|
||||
basePath?: string;
|
||||
/** Whether a scan is in progress */
|
||||
isScanning?: boolean;
|
||||
/** Number of indexed files */
|
||||
indexedFiles?: number;
|
||||
/** Error message if there's an issue */
|
||||
error?: string;
|
||||
};
|
||||
/** Frecency database status */
|
||||
frecency: {
|
||||
/** Whether frecency tracking is initialized */
|
||||
initialized: boolean;
|
||||
/** Database health information */
|
||||
dbHealthcheck?: DbHealth;
|
||||
/** Error message if there's an issue */
|
||||
error?: string;
|
||||
};
|
||||
/** Query tracker status */
|
||||
queryTracker: {
|
||||
/** Whether query tracking is initialized */
|
||||
initialized: boolean;
|
||||
/** Database health information */
|
||||
dbHealthcheck?: DbHealth;
|
||||
/** Error message if there's an issue */
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Options format sent to Rust FFI
|
||||
* @internal
|
||||
*/
|
||||
export interface InitOptionsInternal {
|
||||
base_path: string;
|
||||
frecency_db_path?: string;
|
||||
history_db_path?: string;
|
||||
use_unsafe_no_lock: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Search options format sent to Rust FFI
|
||||
* @internal
|
||||
*/
|
||||
export interface SearchOptionsInternal {
|
||||
max_threads?: number;
|
||||
current_file?: string;
|
||||
combo_boost_multiplier?: number;
|
||||
min_combo_count?: number;
|
||||
page_index?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert public InitOptions to internal format
|
||||
* @internal
|
||||
*/
|
||||
export function toInternalInitOptions(opts: InitOptions): InitOptionsInternal {
|
||||
return {
|
||||
base_path: opts.basePath,
|
||||
frecency_db_path: opts.frecencyDbPath,
|
||||
history_db_path: opts.historyDbPath,
|
||||
use_unsafe_no_lock: opts.useUnsafeNoLock ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert public SearchOptions to internal format
|
||||
* @internal
|
||||
*/
|
||||
export function toInternalSearchOptions(
|
||||
opts?: SearchOptions
|
||||
): SearchOptionsInternal {
|
||||
return {
|
||||
max_threads: opts?.maxThreads,
|
||||
current_file: opts?.currentFile,
|
||||
combo_boost_multiplier: opts?.comboBoostMultiplier,
|
||||
min_combo_count: opts?.minComboCount,
|
||||
page_index: opts?.pageIndex,
|
||||
page_size: opts?.pageSize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Test script for fff package
|
||||
*
|
||||
* Run with: bun packages/fff/test.ts
|
||||
*/
|
||||
|
||||
import { FileFinder } from "./src/index";
|
||||
import { resolve, dirname } from "path";
|
||||
|
||||
async function main() {
|
||||
console.log("=== fff Test Script ===\n");
|
||||
|
||||
// Check if library is available
|
||||
console.log("Checking library availability...");
|
||||
const available = FileFinder.isAvailable();
|
||||
console.log(`Library available: ${available}\n`);
|
||||
|
||||
if (!available) {
|
||||
console.error("Native library not found!");
|
||||
console.error("Build it with: cargo build --release -p fff-c");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Health check (before init)
|
||||
console.log("Health check (before init):");
|
||||
const healthBefore = FileFinder.healthCheck();
|
||||
if (healthBefore.ok) {
|
||||
console.log(` Version: ${healthBefore.value.version}`);
|
||||
console.log(` Git available: ${healthBefore.value.git.available}`);
|
||||
console.log(` File picker initialized: ${healthBefore.value.filePicker.initialized}`);
|
||||
} else {
|
||||
console.error(` Error: ${healthBefore.error}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Initialize with the root project directory to test on more files
|
||||
const testDir = resolve(dirname(import.meta.path), "../..");
|
||||
console.log(`Initializing with base path: ${testDir}`);
|
||||
|
||||
const initResult = FileFinder.init({
|
||||
basePath: testDir,
|
||||
});
|
||||
|
||||
if (!initResult.ok) {
|
||||
console.error(`Init failed: ${initResult.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Initialization successful!\n");
|
||||
|
||||
// Wait for scan with polling to show progress
|
||||
console.log("Waiting for initial scan...");
|
||||
const startTime = Date.now();
|
||||
let lastCount = 0;
|
||||
|
||||
while (FileFinder.isScanning()) {
|
||||
const progress = FileFinder.getScanProgress();
|
||||
if (progress.ok && progress.value.scannedFilesCount !== lastCount) {
|
||||
lastCount = progress.value.scannedFilesCount;
|
||||
console.log(` Scanning: ${lastCount} files...`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
if (Date.now() - startTime > 30000) {
|
||||
console.error(" Scan timeout after 30s");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get final scan progress
|
||||
const progress = FileFinder.getScanProgress();
|
||||
if (progress.ok) {
|
||||
console.log(`Scan complete: ${progress.value.scannedFilesCount} files indexed`);
|
||||
console.log(`Scan time: ${Date.now() - startTime}ms`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Search test
|
||||
console.log("Searching for 'lib.rs'...");
|
||||
const searchResult = FileFinder.search("lib.rs", { pageSize: 5 });
|
||||
|
||||
if (searchResult.ok) {
|
||||
console.log(`Found ${searchResult.value.totalMatched} matches (showing first 5):\n`);
|
||||
for (let i = 0; i < searchResult.value.items.length; i++) {
|
||||
const item = searchResult.value.items[i];
|
||||
const score = searchResult.value.scores[i];
|
||||
console.log(` ${item.relativePath}`);
|
||||
console.log(` Score: ${score.total} (base: ${score.baseScore}, filename: ${score.filenameBonus})`);
|
||||
console.log(` Git: ${item.gitStatus}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`Search failed: ${searchResult.error}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Search with different query
|
||||
console.log("Searching for 'package.json'...");
|
||||
const searchResult2 = FileFinder.search("package.json", { pageSize: 3 });
|
||||
|
||||
if (searchResult2.ok) {
|
||||
console.log(`Found ${searchResult2.value.totalMatched} matches:\n`);
|
||||
for (const item of searchResult2.value.items) {
|
||||
console.log(` ${item.relativePath}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`Search failed: ${searchResult2.error}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Health check (after init)
|
||||
console.log("Health check (after init):");
|
||||
const healthAfter = FileFinder.healthCheck();
|
||||
if (healthAfter.ok) {
|
||||
console.log(` File picker initialized: ${healthAfter.value.filePicker.initialized}`);
|
||||
console.log(` Base path: ${healthAfter.value.filePicker.basePath}`);
|
||||
console.log(` Indexed files: ${healthAfter.value.filePicker.indexedFiles}`);
|
||||
if (healthAfter.value.git.repositoryFound) {
|
||||
console.log(` Git workdir: ${healthAfter.value.git.workdir}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Cleanup
|
||||
console.log("Cleaning up...");
|
||||
const destroyResult = FileFinder.destroy();
|
||||
if (destroyResult.ok) {
|
||||
console.log("Cleanup successful!");
|
||||
} else {
|
||||
console.error(`Cleanup failed: ${destroyResult.error}`);
|
||||
}
|
||||
|
||||
console.log("\n=== Test Complete ===");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src/**/*", "scripts/**/*", "*.ts"],
|
||||
"exclude": ["node_modules", "dist", "bin"]
|
||||
}
|
||||
+4
-1
@@ -63,7 +63,7 @@ vim.api.nvim_create_user_command('FFFClearCache', function(opts) require('fff').
|
||||
desc = 'Clear FFF caches (all|frecency|files)',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFHealth', function() require('fff').health_check() end, {
|
||||
vim.api.nvim_create_user_command('FFFHealth', function() vim.cmd('checkhealth fff') end, {
|
||||
desc = 'Check FFF health',
|
||||
})
|
||||
|
||||
@@ -71,13 +71,16 @@ vim.api.nvim_create_user_command('FFFDebug', function(opts)
|
||||
local config = require('fff.conf').get()
|
||||
if opts.args == 'toggle' or opts.args == '' then
|
||||
config.debug.show_scores = not config.debug.show_scores
|
||||
config.debug.show_file_info = config.debug.show_scores
|
||||
local status = config.debug.show_scores and 'enabled' or 'disabled'
|
||||
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
|
||||
elseif opts.args == 'on' then
|
||||
config.debug.show_scores = true
|
||||
config.debug.show_file_info = true
|
||||
vim.notify('FFF debug scores enabled', vim.log.levels.INFO)
|
||||
elseif opts.args == 'off' then
|
||||
config.debug.show_scores = false
|
||||
config.debug.show_file_info = false
|
||||
vim.notify('FFF debug scores disabled', vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('Usage: :FFFDebug [on|off|toggle]', vim.log.levels.ERROR)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2025-09-01"
|
||||
channel = "nightly-2026-02-10"
|
||||
components = [
|
||||
"clippy-preview",
|
||||
"rustfmt-preview",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
local fff_rust = require('fff.rust')
|
||||
|
||||
--- Wait for the scan to fully complete, handling the startup race where
|
||||
--- the background thread hasn't set is_scanning=true yet.
|
||||
--- @param timeout_ms number Maximum time to wait in milliseconds
|
||||
local function wait_for_scan(timeout_ms)
|
||||
-- Small sleep to let the background thread start and set is_scanning=true.
|
||||
-- This handles the race between init_file_picker returning and the thread starting.
|
||||
vim.wait(100, function() return false end)
|
||||
fff_rust.wait_for_initial_scan(timeout_ms)
|
||||
end
|
||||
|
||||
describe('fff.nvim core', function()
|
||||
local test_dir
|
||||
|
||||
before_each(function()
|
||||
-- Use the plugin's own repo directory as a known git repo for testing
|
||||
test_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
|
||||
-- Make sure it resolves to an actual directory
|
||||
if vim.fn.isdirectory(test_dir) ~= 1 then test_dir = vim.fn.getcwd() end
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
-- Cleanup: stop background monitor and release the file picker
|
||||
pcall(fff_rust.stop_background_monitor)
|
||||
pcall(fff_rust.cleanup_file_picker)
|
||||
end)
|
||||
|
||||
describe('init and scan', function()
|
||||
it('should initialize the file picker and scan files', function()
|
||||
local ok = fff_rust.init_file_picker(test_dir)
|
||||
assert.is_true(ok)
|
||||
|
||||
wait_for_scan(10000)
|
||||
|
||||
local progress = fff_rust.get_scan_progress()
|
||||
assert.is_not_nil(progress)
|
||||
assert.is_number(progress.scanned_files_count)
|
||||
assert.is_true(
|
||||
progress.scanned_files_count > 0,
|
||||
'expected scanned files > 0, got ' .. progress.scanned_files_count
|
||||
)
|
||||
assert.is_false(progress.is_scanning)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('fuzzy search', function()
|
||||
it('should return results for a known query', function()
|
||||
local ok = fff_rust.init_file_picker(test_dir)
|
||||
assert.is_true(ok)
|
||||
wait_for_scan(10000)
|
||||
|
||||
-- Search for "main" which should match main.lua and possibly other files
|
||||
-- Args: query, max_threads, current_file, combo_boost_score_multiplier, min_combo_count, offset, page_size
|
||||
local result = fff_rust.fuzzy_search_files('main', 2, nil, 100, 3, 0, 10)
|
||||
assert.is_not_nil(result)
|
||||
assert.is_not_nil(result.items)
|
||||
assert.is_true(#result.items > 0, 'expected search results for "main"')
|
||||
|
||||
-- Each item should have required fields
|
||||
local first = result.items[1]
|
||||
assert.is_not_nil(first.relative_path)
|
||||
assert.is_string(first.relative_path)
|
||||
end)
|
||||
|
||||
it('should return empty results for nonsense query', function()
|
||||
local ok = fff_rust.init_file_picker(test_dir)
|
||||
assert.is_true(ok)
|
||||
wait_for_scan(10000)
|
||||
|
||||
local result = fff_rust.fuzzy_search_files('zzzxxxqqq_no_match_ever', 2, nil, 100, 3, 0, 10)
|
||||
assert.is_not_nil(result)
|
||||
assert.is_not_nil(result.items)
|
||||
assert.are.equal(0, #result.items)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('git root detection', function()
|
||||
it('should return the git root for a git repository', function()
|
||||
local ok = fff_rust.init_file_picker(test_dir)
|
||||
assert.is_true(ok)
|
||||
wait_for_scan(10000)
|
||||
|
||||
local git_root = fff_rust.get_git_root()
|
||||
assert.is_not_nil(git_root, 'expected git root to be found in the plugin repo')
|
||||
assert.is_string(git_root)
|
||||
-- The git root should be a real directory
|
||||
assert.are.equal(1, vim.fn.isdirectory(git_root), 'git root should be a valid directory: ' .. git_root)
|
||||
end)
|
||||
|
||||
it('should return nil for a non-git directory', function()
|
||||
-- Use a temp directory that is definitely not a git repo
|
||||
local tmp_dir = vim.fn.tempname()
|
||||
vim.fn.mkdir(tmp_dir, 'p')
|
||||
|
||||
local ok = fff_rust.init_file_picker(tmp_dir)
|
||||
assert.is_true(ok)
|
||||
wait_for_scan(10000)
|
||||
|
||||
local git_root = fff_rust.get_git_root()
|
||||
assert.is_nil(git_root)
|
||||
|
||||
vim.fn.delete(tmp_dir, 'rf')
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('health check', function()
|
||||
it('should return version and component status', function()
|
||||
local ok = fff_rust.init_file_picker(test_dir)
|
||||
assert.is_true(ok)
|
||||
wait_for_scan(10000)
|
||||
|
||||
local health = fff_rust.health_check(test_dir)
|
||||
assert.is_not_nil(health)
|
||||
assert.is_string(health.version)
|
||||
|
||||
-- Git info should be present
|
||||
assert.is_not_nil(health.git)
|
||||
assert.is_true(health.git.available)
|
||||
assert.is_string(health.git.libgit2_version)
|
||||
|
||||
-- File picker should be initialized
|
||||
assert.is_not_nil(health.file_picker)
|
||||
assert.is_true(health.file_picker.initialized)
|
||||
assert.is_string(health.file_picker.base_path)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -0,0 +1,17 @@
|
||||
--- Minimal test runner for plenary busted-style tests
|
||||
--- Usage: nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal_init.lua'}"
|
||||
|
||||
-- Set up runtimepath to include the plugin and plenary
|
||||
local plugin_dir = vim.fn.fnamemodify(vim.fn.resolve(vim.fn.expand('<sfile>:p')), ':h:h')
|
||||
local plenary_dir = os.getenv('PLENARY_DIR') or (plugin_dir .. '/../plenary.nvim')
|
||||
|
||||
vim.opt.runtimepath:prepend(plugin_dir)
|
||||
vim.opt.runtimepath:prepend(plenary_dir)
|
||||
|
||||
-- Disable swap files and other noise for testing
|
||||
vim.o.swapfile = false
|
||||
vim.o.backup = false
|
||||
vim.o.writebackup = false
|
||||
|
||||
-- Set cwd to the plugin directory so test_dir resolution is reliable
|
||||
vim.cmd('cd ' .. vim.fn.fnameescape(plugin_dir))
|
||||
Reference in New Issue
Block a user