Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac8df4c9e4 | |||
| e3e534f4ad | |||
| 764e3ecf18 | |||
| 10a27f9678 | |||
| 66e712e066 | |||
| c5a3f89c5e | |||
| 29f81a141f | |||
| d2e3993398 | |||
| a411100fa4 | |||
| 0a993d7a30 | |||
| 7c2d46f1e2 | |||
| c3ed9fb17b | |||
| 0f40c66eb7 | |||
| 7298978bcb | |||
| 8f69f987a4 | |||
| 3e9b8655b7 |
+3
-8
@@ -10,12 +10,7 @@ rustflags = ["-C", "target-feature=-crt-static"]
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
rustflags = ["-C", "target-feature=-crt-static"]
|
||||
|
||||
# Android/Termux: no hardcoded linker so native Termux builds use the system cc.
|
||||
# For CI cross-compilation the linker is set via CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER env var.
|
||||
[target.aarch64-linux-android]
|
||||
rustflags = [
|
||||
"-C",
|
||||
"linker=aarch64-linux-android-clang",
|
||||
"-C",
|
||||
"link-args=-rdynamic",
|
||||
"-C",
|
||||
"default-linker-libraries",
|
||||
]
|
||||
rustflags = ["-C", "link-args=-rdynamic"]
|
||||
|
||||
@@ -14,6 +14,8 @@ jobs:
|
||||
lua-tests:
|
||||
name: e2e (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
# e2e tests could be flaky on CI so we do not block release creation if they failed
|
||||
continue-on-error: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -42,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Build Rust binary (Windows)
|
||||
if: matrix.target
|
||||
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim
|
||||
run: cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
|
||||
- name: Copy binary to target/release (Windows)
|
||||
if: matrix.target
|
||||
@@ -69,7 +71,7 @@ jobs:
|
||||
|
||||
- name: Build Rust binary
|
||||
if: ${{ !matrix.target }}
|
||||
run: cargo build --release -p fff-nvim
|
||||
run: cargo build --release -p fff-nvim --features zlob
|
||||
|
||||
- name: Install Neovim
|
||||
uses: rhysd/action-setup-vim@v1
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Lua CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lua-ls:
|
||||
name: lua-language-server type check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Neovim
|
||||
run: |
|
||||
curl -L https://github.com/neovim/neovim/releases/download/v0.11.5/nvim-linux-x86_64.tar.gz -o /opt/nvim.tar.gz
|
||||
mkdir /opt/nvim
|
||||
tar xzf /opt/nvim.tar.gz -C /opt/nvim
|
||||
mv /opt/nvim/nvim-linux-x86_64/* /opt/nvim
|
||||
echo "/opt/nvim/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install lua-language-server
|
||||
run: |
|
||||
curl -L "https://github.com/LuaLS/lua-language-server/releases/download/3.17.1/lua-language-server-3.17.1-linux-x64.tar.gz" -o /opt/lls.tar.gz
|
||||
mkdir /opt/lls
|
||||
tar -xzf /opt/lls.tar.gz -C /opt/lls
|
||||
echo "/opt/lls/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Clone snacks.nvim
|
||||
run: git clone --depth=1 https://github.com/folke/snacks.nvim /opt/snacks.nvim
|
||||
|
||||
- name: Run lua-language-server
|
||||
run: lua-language-server --configpath .luarc.ci.json --check=.
|
||||
|
||||
luacheck:
|
||||
name: luacheck lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install luacheck
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y luarocks
|
||||
sudo luarocks install luacheck
|
||||
|
||||
- name: Run luacheck
|
||||
run: luacheck lua/
|
||||
@@ -2,7 +2,7 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/interchangable-ffi]
|
||||
branches: [main, feat/interchangable-ffi, feat/termux]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -36,6 +36,12 @@ jobs:
|
||||
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
@@ -73,22 +79,40 @@ jobs:
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
if: contains(matrix.target, 'android')
|
||||
run: |
|
||||
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
# NDK clang for C deps (libgit2, lmdb, blake3) that need Bionic sysroot headers
|
||||
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
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
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "${{ 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
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-nvim --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
@@ -133,6 +157,13 @@ jobs:
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## Android (Termux)
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: target/aarch64-linux-android/release/libfff_c.so
|
||||
lib_filename: libfff_c.so
|
||||
ext: so
|
||||
|
||||
## macOS builds
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
@@ -179,25 +210,43 @@ jobs:
|
||||
run: cargo install cargo-zigbuild
|
||||
|
||||
- name: Build for Linux
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
if: contains(matrix.os, 'ubuntu') && !contains(matrix.target, 'android')
|
||||
run: |
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c
|
||||
cargo zigbuild --release --target ${{ matrix.zigbuild_target || matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Android (Termux)
|
||||
if: contains(matrix.target, 'android')
|
||||
run: |
|
||||
NDK_BIN="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
|
||||
export CC_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
export CXX_aarch64_linux_android="$NDK_BIN/aarch64-linux-android24-clang++"
|
||||
export AR_aarch64_linux_android="$NDK_BIN/llvm-ar"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_BIN/aarch64-linux-android24-clang"
|
||||
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ 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-c
|
||||
MACOSX_DEPLOYMENT_TARGET="13" cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Ad-hoc sign macOS binary
|
||||
if: contains(matrix.os, 'macos')
|
||||
run: codesign --force --sign - "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Build for Windows
|
||||
if: contains(matrix.os, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c
|
||||
cargo build --release --target ${{ matrix.target }} -p fff-c --features zlob
|
||||
mv "${{ matrix.artifact_name }}" "c-lib-${{ matrix.target }}.${{ matrix.ext }}"
|
||||
|
||||
- name: Prepare npm package
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
shell: bash
|
||||
run: |
|
||||
# Copy the built binary into the platform npm package directory
|
||||
@@ -210,6 +259,7 @@ jobs:
|
||||
path: c-lib-${{ matrix.target }}.*
|
||||
|
||||
- name: Upload npm package artifact
|
||||
if: "!contains(matrix.target, 'android')"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.npm_package }}
|
||||
@@ -219,6 +269,8 @@ jobs:
|
||||
name: Release
|
||||
needs: [build-nvim, build-c]
|
||||
runs-on: ubuntu-latest
|
||||
# do not create releases on the forks (no permissions)
|
||||
if: github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -395,7 +447,8 @@ jobs:
|
||||
name: Comment on PR
|
||||
needs: [build-nvim, build-c]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
# comments doesn't work on forks
|
||||
if: github.event_name == 'pull_request' && github.repository == 'dmtrKovalenko/fff.nvim'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
|
||||
@@ -37,7 +37,9 @@ jobs:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose --workspace --exclude fff-nvim
|
||||
run: |
|
||||
cargo test --verbose -p fff-core -p fff-query-parser -p fff-c --features zlob
|
||||
cargo test --verbose -p grep-searcher
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
@@ -71,4 +73,6 @@ jobs:
|
||||
components: clippy
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
run: |
|
||||
cargo clippy -p fff-core -p fff-query-parser -p fff-nvim -p fff-c --features zlob -- -D warnings
|
||||
cargo clippy -p grep-searcher -- -D warnings
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
-- luacheck configuration for fff.nvim
|
||||
-- https://luacheck.readthedocs.io/en/stable/config.html
|
||||
|
||||
-- Neovim globals
|
||||
globals = { "vim" }
|
||||
|
||||
-- Standard library
|
||||
std = "luajit"
|
||||
|
||||
-- Ignore line length (handled by stylua)
|
||||
max_line_length = false
|
||||
|
||||
-- Ignore unused self argument in methods
|
||||
self = false
|
||||
|
||||
-- Files/directories to ignore
|
||||
exclude_files = {
|
||||
".luarocks/",
|
||||
}
|
||||
|
||||
-- Warn about unused variables, but allow _ prefix convention
|
||||
unused_args = true
|
||||
ignore = {
|
||||
"212", -- unused argument (too noisy for callback-heavy code)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
|
||||
"runtime": {
|
||||
"version": "LuaJIT",
|
||||
"pathStrict": true
|
||||
},
|
||||
"workspace": {
|
||||
"library": [
|
||||
"/opt/nvim/share/nvim/runtime/lua/vim/_meta",
|
||||
"/opt/nvim/share/nvim/runtime/lua/vim/shared.lua",
|
||||
"${3rd}/luv/library",
|
||||
"${3rd}/busted/library",
|
||||
"/opt/snacks.nvim/lua"
|
||||
],
|
||||
"checkThirdParty": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"severity": {
|
||||
"undefined-global": "Error",
|
||||
"undefined-field": "Warning",
|
||||
"missing-return": "Warning",
|
||||
"redundant-parameter": "Warning",
|
||||
"param-type-mismatch": "Warning",
|
||||
"assign-type-mismatch": "Warning",
|
||||
"cast-type-mismatch": "Warning",
|
||||
"deprecated": "Warning",
|
||||
"undefined-doc-param": "Warning"
|
||||
},
|
||||
"neededFileStatus": {
|
||||
"undefined-global": "Any",
|
||||
"undefined-field": "Any",
|
||||
"missing-return": "Any",
|
||||
"redundant-parameter": "Any",
|
||||
"param-type-mismatch": "Any",
|
||||
"assign-type-mismatch": "Any",
|
||||
"cast-type-mismatch": "Any",
|
||||
"deprecated": "Any",
|
||||
"undefined-doc-param": "Any"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"checkTableShape": true
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
|
||||
"runtime": {
|
||||
"version": "LuaJIT"
|
||||
},
|
||||
"workspace": {
|
||||
"library": [
|
||||
"/opt/homebrew/share/nvim/runtime/lua/vim/_meta",
|
||||
"/opt/homebrew/share/nvim/runtime/lua/vim/shared.lua",
|
||||
"${3rd}/luv/library",
|
||||
"${3rd}/busted/library"
|
||||
],
|
||||
"checkThirdParty": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"severity": {
|
||||
"undefined-global": "Error",
|
||||
"undefined-field": "Warning",
|
||||
"missing-return": "Warning",
|
||||
"redundant-parameter": "Warning",
|
||||
"param-type-mismatch": "Warning",
|
||||
"assign-type-mismatch": "Warning",
|
||||
"cast-type-mismatch": "Warning",
|
||||
"deprecated": "Warning",
|
||||
"undefined-doc-param": "Warning"
|
||||
},
|
||||
"neededFileStatus": {
|
||||
"undefined-global": "Any",
|
||||
"undefined-field": "Any",
|
||||
"missing-return": "Any",
|
||||
"redundant-parameter": "Any",
|
||||
"param-type-mismatch": "Any",
|
||||
"assign-type-mismatch": "Any",
|
||||
"cast-type-mismatch": "Any",
|
||||
"deprecated": "Any",
|
||||
"undefined-doc-param": "Any"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"checkTableShape": true
|
||||
}
|
||||
}
|
||||
Generated
+3
-68
@@ -482,6 +482,7 @@ dependencies = [
|
||||
"fff-query-parser",
|
||||
"git2",
|
||||
"glidesort",
|
||||
"globset",
|
||||
"grep-matcher",
|
||||
"grep-searcher",
|
||||
"heed",
|
||||
@@ -492,7 +493,6 @@ dependencies = [
|
||||
"notify",
|
||||
"notify-debouncer-full 0.7.0",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"parking_lot",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
@@ -531,7 +531,6 @@ dependencies = [
|
||||
"notify",
|
||||
"notify-debouncer-full 0.6.0",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"rayon",
|
||||
@@ -543,7 +542,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"zlob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -564,21 +562,6 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.1"
|
||||
@@ -1299,54 +1282,6 @@ version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.73"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.1+3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2574,9 +2509,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zlob"
|
||||
version = "1.2.9"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "674f4e74544c0a00887c0dff7862fe13742de8c5dbcf6db9eac0643362554d44"
|
||||
checksum = "07934fb13f0f4e14281bab6b0e984cfc03891d2cf5b0a8bd0ab14fd240e21106"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.9.1",
|
||||
|
||||
+2
-1
@@ -22,13 +22,14 @@ git2 = { version = "0.20.2", default-features = false, features = [
|
||||
"vendored-libgit2",
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
globset = "0.4"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-searcher = { path = "crates/fff-searcher" }
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
memmap2 = "0.9"
|
||||
mimalloc = "0.1.47"
|
||||
zlob = "1.2.9"
|
||||
zlob = "1.3.0"
|
||||
|
||||
mlua = { version = "0.11.1", features = ["module", "luajit"] }
|
||||
neo_frizbee = "0.8.1"
|
||||
|
||||
@@ -3,7 +3,7 @@ PLENARY_DIR ?= ../plenary.nvim
|
||||
.PHONY: build test test-rust test-lua test-bun test-setup prepare-bun
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
cargo build --release --features zlob
|
||||
|
||||
test-setup:
|
||||
@if [ ! -d "$(PLENARY_DIR)" ]; then \
|
||||
@@ -12,7 +12,7 @@ test-setup:
|
||||
fi
|
||||
|
||||
test-rust:
|
||||
cargo test --workspace
|
||||
cargo test --workspace --features zlob
|
||||
|
||||
test-lua: test-setup build
|
||||
nvim --headless -u tests/minimal_init.lua \
|
||||
|
||||
@@ -78,12 +78,17 @@ FFF.nvim requires:
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
}
|
||||
},
|
||||
{
|
||||
"fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -136,6 +141,10 @@ require('fff').setup({
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to false to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
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)
|
||||
@@ -151,6 +160,7 @@ require('fff').setup({
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
@@ -171,7 +181,7 @@ require('fff').setup({
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
toggle_grep_regex = '<S-Tab>',
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
@@ -184,16 +194,15 @@ require('fff').setup({
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -222,9 +231,10 @@ require('fff').setup({
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
@@ -235,8 +245,8 @@ require('fff').setup({
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
@@ -252,15 +262,19 @@ require('fff').setup({
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- Live grep search configuration
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Key Features
|
||||
@@ -268,8 +282,7 @@ require('fff').setup({
|
||||
#### Available Methods
|
||||
|
||||
```lua
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
require('fff').find_files() -- Find files in current repositro
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
|
||||
@@ -8,6 +8,10 @@ license = "MIT"
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[dependencies]
|
||||
mimalloc.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -13,6 +13,9 @@ crate-type = ["rlib", "staticlib", "cdylib"]
|
||||
default = []
|
||||
# Enable C FFI exports
|
||||
ffi = []
|
||||
# Use zlob (Zig-compiled C globbing library) for glob matching.
|
||||
# Requires Zig to be installed. When disabled, falls back to globset (pure Rust).
|
||||
zlob = ["dep:zlob", "fff-query-parser/zlob"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
@@ -23,7 +26,7 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
fff-query-parser = { path = "../fff-query-parser" }
|
||||
fff-query-parser = { path = "../fff-query-parser", default-features = false }
|
||||
|
||||
# External dependencies
|
||||
bindet = { workspace = true }
|
||||
@@ -32,6 +35,7 @@ chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
git2 = { workspace = true }
|
||||
glidesort = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
grep-matcher = { workspace = true }
|
||||
grep-searcher = { workspace = true }
|
||||
memchr = "2"
|
||||
@@ -49,12 +53,7 @@ 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 = { workspace = true }
|
||||
|
||||
# Platform-specific: Use vendored OpenSSL on non-Windows (Linux, macOS)
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
zlob = { workspace = true, optional = true }
|
||||
# Platform-specific: dunce for Windows to avoid \\?\ extended path prefix
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
dunce = { workspace = true }
|
||||
|
||||
+42
-12
@@ -1,16 +1,46 @@
|
||||
fn main() {
|
||||
// When the `zlob` feature is enabled (Zig-compiled C library):
|
||||
// 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");
|
||||
// Zig-compiled static libraries don't emit /DEFAULTLIB directives for the
|
||||
// MSVC CRT, so symbols like strcmp, memcpy etc. would be unresolved.
|
||||
if std::env::var("CARGO_FEATURE_ZLOB").is_ok() {
|
||||
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");
|
||||
}
|
||||
} else if std::env::var("CI").is_ok() {
|
||||
// CI must always build with zlob for production-quality binaries.
|
||||
if !zig_available() {
|
||||
panic!(
|
||||
"CI detected but Zig is not installed. \
|
||||
Please install Zig and build with `--features zlob`."
|
||||
);
|
||||
}
|
||||
panic!(
|
||||
"CI detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob`."
|
||||
);
|
||||
} else {
|
||||
// Hint: if Zig is available but the zlob feature wasn't enabled,
|
||||
// let the developer know they can get faster glob matching.
|
||||
if zig_available() {
|
||||
println!(
|
||||
"cargo:warning=Zig detected but `zlob` feature is not enabled. \
|
||||
Build with `--features zlob` for faster glob matching."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the system for a working Zig installation.
|
||||
fn zig_available() -> bool {
|
||||
std::process::Command::new("zig")
|
||||
.arg("version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
use ahash::AHashSet;
|
||||
use fff_query_parser::{Constraint, GitStatusFilter};
|
||||
use smallvec::SmallVec;
|
||||
use zlob::{ZlobFlags, zlob_match_paths};
|
||||
|
||||
use crate::git::is_modified_status;
|
||||
|
||||
@@ -235,32 +234,8 @@ fn collect_glob_indices<'a>(
|
||||
) {
|
||||
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()));
|
||||
}
|
||||
let indices = match_glob_pattern(pattern, paths);
|
||||
results.push((is_negated, indices));
|
||||
}
|
||||
Constraint::Not(inner) => {
|
||||
collect_glob_indices(inner, paths, results, !is_negated);
|
||||
@@ -269,6 +244,66 @@ fn collect_glob_indices<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a glob pattern against a list of paths, returning the set of matching indices.
|
||||
///
|
||||
/// When the `zlob` feature is enabled, delegates to `zlob::zlob_match_paths` (Zig-compiled
|
||||
/// C library, fastest). Otherwise falls back to `globset::Glob` (pure Rust).
|
||||
#[cfg(feature = "zlob")]
|
||||
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
|
||||
let Ok(Some(matches)) = zlob::zlob_match_paths(pattern, paths, zlob::ZlobFlags::RECOMMENDED)
|
||||
else {
|
||||
return AHashSet::new();
|
||||
};
|
||||
|
||||
let matched_set: AHashSet<usize> = matches.iter().map(|s| s.as_ptr() as usize).collect();
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
fn match_glob_pattern(pattern: &str, paths: &[&str]) -> AHashSet<usize> {
|
||||
let Ok(glob) = globset::Glob::new(pattern) else {
|
||||
return AHashSet::new();
|
||||
};
|
||||
let matcher = glob.compile_matcher();
|
||||
|
||||
if paths.len() >= PAR_THRESHOLD {
|
||||
use rayon::prelude::*;
|
||||
paths
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matcher.is_match(p))
|
||||
.map(|(i, _)| i)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| matcher.is_match(p))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -186,6 +186,7 @@ pub struct FilePicker {
|
||||
scanned_files_count: Arc<AtomicUsize>,
|
||||
background_watcher: Option<BackgroundWatcher>,
|
||||
warmup_mmap_cache: bool,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FilePicker {
|
||||
@@ -250,6 +251,7 @@ impl FilePicker {
|
||||
// rather than a stale `false` (the thread hasn't started yet).
|
||||
let scan_signal = Arc::new(AtomicBool::new(true));
|
||||
let synced_files_count = Arc::new(AtomicUsize::new(0));
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let picker = FilePicker {
|
||||
base_path: path.clone(),
|
||||
@@ -258,6 +260,7 @@ impl FilePicker {
|
||||
scanned_files_count: Arc::clone(&synced_files_count),
|
||||
background_watcher: None,
|
||||
warmup_mmap_cache,
|
||||
cancelled: Arc::clone(&cancelled),
|
||||
};
|
||||
|
||||
// Place the picker into the shared handle before spawning the
|
||||
@@ -274,6 +277,7 @@ impl FilePicker {
|
||||
warmup_mmap_cache,
|
||||
shared_picker,
|
||||
shared_frecency,
|
||||
cancelled,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -578,6 +582,11 @@ impl FilePicker {
|
||||
.retain_files(|file| !file.path.starts_with(dir_path))
|
||||
}
|
||||
|
||||
/// We use this to prevent any substantial background threads from acquiring the locks
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn stop_background_monitor(&mut self) {
|
||||
if let Some(watcher) = self.background_watcher.take() {
|
||||
watcher.stop();
|
||||
@@ -644,6 +653,7 @@ fn spawn_scan_and_watcher(
|
||||
warmup_mmap_cache: bool,
|
||||
shared_picker: SharedPicker,
|
||||
shared_frecency: SharedFrecency,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
// scan_signal is already `true` (set by the caller before spawning)
|
||||
@@ -653,6 +663,12 @@ fn spawn_scan_and_watcher(
|
||||
let mut git_workdir = None;
|
||||
match scan_filesystem(&base_path, &synced_files_count, &shared_frecency) {
|
||||
Ok(sync) => {
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Scan completed but picker was replaced, discarding results");
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Initial filesystem scan completed: found {} files",
|
||||
sync.files.len()
|
||||
@@ -672,13 +688,8 @@ fn spawn_scan_and_watcher(
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Warmup mmap cache in background to avoid blocking first grep.
|
||||
// The aggressive parallel warmup was causing cache thrashing and delaying
|
||||
// initial searches. Now it runs async and doesn't block.
|
||||
//
|
||||
// We warmup under a read lock on the picker's actual files so that
|
||||
// the OnceLock<Mmap> instances are populated in-place — no clone needed.
|
||||
// Read locks allow concurrent readers so this doesn't block searches.
|
||||
if warmup_mmap_cache
|
||||
&& !cancelled.load(Ordering::Acquire)
|
||||
&& let Ok(guard) = shared_picker.read()
|
||||
&& let Some(ref picker) = *guard
|
||||
{
|
||||
@@ -691,6 +702,12 @@ fn spawn_scan_and_watcher(
|
||||
}
|
||||
scan_signal.store(false, Ordering::Relaxed);
|
||||
|
||||
// Don't create a watcher if this picker instance was already replaced
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, skipping background watcher creation");
|
||||
return;
|
||||
}
|
||||
|
||||
match BackgroundWatcher::new(
|
||||
base_path,
|
||||
git_workdir,
|
||||
@@ -700,6 +717,15 @@ fn spawn_scan_and_watcher(
|
||||
Ok(watcher) => {
|
||||
info!("Background file watcher initialized successfully");
|
||||
|
||||
// Final cancellation check: if the picker was replaced between
|
||||
// watcher creation and this write, drop the watcher instead of
|
||||
// storing it in the wrong picker.
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
info!("Picker was replaced, dropping orphaned watcher");
|
||||
drop(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
let write_result = shared_picker.write().ok().map(|mut guard| {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
picker.background_watcher = Some(watcher);
|
||||
|
||||
+33
-18
@@ -707,24 +707,39 @@ fn fuzzy_grep_search<'a>(
|
||||
// 3-5 chars → 1 typo
|
||||
// 6+ chars → 2 typos
|
||||
let max_typos = (grep_text.len() / 3).min(2);
|
||||
let frizbee_config = neo_frizbee::Config {
|
||||
let scoring = neo_frizbee::Scoring {
|
||||
// Use default gap penalties. Higher values (e.g. 20) cause
|
||||
// smith-waterman to prefer *dropping needle chars* over paying
|
||||
// gap costs, which inflates the typo count and breaks
|
||||
// transposition matching ("shcema" → "schema" becomes 3 typos
|
||||
// instead of 1). Scattered matches are filtered by max_typos
|
||||
// and the match span check below instead.
|
||||
exact_match_bonus: 100,
|
||||
// gap_open_penalty: 4,
|
||||
// gap_extend_penalty: 2,
|
||||
prefix_bonus: 0,
|
||||
capitalization_bonus: if case_insensitive { 0 } else { 4 },
|
||||
..neo_frizbee::Scoring::default()
|
||||
};
|
||||
|
||||
// Two configs: match_list uses a lenient max_typos (needle_len) to keep
|
||||
// the SIMD prefilter active (cheap char-presence check) while avoiding
|
||||
// aggressive SIMD typo rejection that can disagree with the reference
|
||||
// implementation on some architectures (e.g. aarch64 portable SIMD).
|
||||
// match_indices uses the actual max_typos for correct typo filtering
|
||||
// via the reference (scalar) Smith-Waterman traceback.
|
||||
let match_list_config = neo_frizbee::Config {
|
||||
prefilter: true, // SIMD prefilter rejects obvious non-matches cheaply
|
||||
max_typos: Some(max_typos as u16),
|
||||
max_typos: Some(grep_text.len() as u16),
|
||||
sort: false, // We handle ordering ourselves
|
||||
scoring: neo_frizbee::Scoring {
|
||||
// Use default gap penalties. Higher values (e.g. 20) cause
|
||||
// smith-waterman to prefer *dropping needle chars* over paying
|
||||
// gap costs, which inflates the typo count and breaks
|
||||
// transposition matching ("shcema" → "schema" becomes 3 typos
|
||||
// instead of 1). Scattered matches are filtered by max_typos
|
||||
// and the match span check below instead.
|
||||
exact_match_bonus: 100,
|
||||
// gap_open_penalty: 4,
|
||||
// gap_extend_penalty: 2,
|
||||
prefix_bonus: 0,
|
||||
capitalization_bonus: if case_insensitive { 0 } else { 4 },
|
||||
..neo_frizbee::Scoring::default()
|
||||
},
|
||||
scoring,
|
||||
};
|
||||
|
||||
let match_indices_config = neo_frizbee::Config {
|
||||
prefilter: false,
|
||||
max_typos: Some(max_typos as u16),
|
||||
sort: false,
|
||||
scoring,
|
||||
};
|
||||
|
||||
// Minimum score threshold: 50% of a perfect contiguous match.
|
||||
@@ -797,7 +812,7 @@ fn fuzzy_grep_search<'a>(
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let matches = neo_frizbee::match_list(grep_text, &file_lines, &frizbee_config);
|
||||
let matches = neo_frizbee::match_list(grep_text, &file_lines, &match_list_config);
|
||||
let mut file_matches: Vec<GrepMatch> = Vec::new();
|
||||
|
||||
for m in &matches {
|
||||
@@ -822,7 +837,7 @@ fn fuzzy_grep_search<'a>(
|
||||
};
|
||||
|
||||
let Some(match_indices) =
|
||||
neo_frizbee::match_indices(grep_text, display_line, &frizbee_config)
|
||||
neo_frizbee::match_indices(grep_text, display_line, &match_indices_config)
|
||||
else {
|
||||
continue; // something is off treat as nomatch
|
||||
};
|
||||
|
||||
@@ -7,6 +7,10 @@ edition = "2024"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["fff-core/zlob"]
|
||||
|
||||
[[bin]]
|
||||
name = "test_watcher"
|
||||
path = "src/bin/test_watcher.rs"
|
||||
@@ -63,7 +67,6 @@ 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 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
@@ -78,7 +81,3 @@ harness = false
|
||||
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"] }
|
||||
|
||||
+32
-12
@@ -100,17 +100,24 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
|
||||
}
|
||||
|
||||
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
|
||||
// Stop existing picker
|
||||
// Cancel and stop the old picker under a single write lock to avoid
|
||||
// a window where FILE_PICKER is None (which causes FilePickerMissing
|
||||
// errors if the UI is searching concurrently).
|
||||
{
|
||||
let mut guard = FILE_PICKER
|
||||
.write()
|
||||
.with_lock_error(Error::AcquireItemLock)?;
|
||||
if let Some(mut picker) = guard.take() {
|
||||
if let Some(ref mut picker) = *guard {
|
||||
// Signal cancellation BEFORE stopping — this tells any orphaned
|
||||
// scan threads from this picker to discard their results.
|
||||
picker.cancel();
|
||||
picker.stop_background_monitor();
|
||||
}
|
||||
// Don't take() here — leave the old picker in place so searches
|
||||
// still work until new_with_shared_state replaces it atomically.
|
||||
}
|
||||
|
||||
// Create new picker backed by the same shared state
|
||||
// Create new picker — this atomically replaces the old one via write lock
|
||||
FilePicker::new_with_shared_state(
|
||||
path.to_string_lossy().to_string(),
|
||||
false,
|
||||
@@ -134,6 +141,12 @@ pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
|
||||
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
|
||||
})?;
|
||||
|
||||
if let Ok(Some(picker)) = FILE_PICKER.read().as_deref()
|
||||
&& picker.base_path() == canonical_path
|
||||
{
|
||||
return Ok(()); // same dir
|
||||
}
|
||||
|
||||
// Spawn a background thread to avoid blocking Lua/UI thread
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
|
||||
@@ -564,21 +577,28 @@ pub fn get_historical_grep_query(_: &Lua, offset: usize) -> LuaResult<Option<Str
|
||||
}
|
||||
|
||||
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()?;
|
||||
// Extract the scan signal Arc WITHOUT holding the read lock, so the
|
||||
// scan thread can acquire the write lock to store its results.
|
||||
// Holding a read lock while polling would deadlock: the scan thread
|
||||
// needs a write lock to finish, but can't acquire it while we hold the read lock.
|
||||
let scan_signal = {
|
||||
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()?;
|
||||
picker.scan_signal()
|
||||
}; // read lock released here
|
||||
|
||||
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() {
|
||||
while scan_signal.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if start_time.elapsed() >= timeout_duration {
|
||||
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
|
||||
return Ok(false);
|
||||
|
||||
@@ -6,9 +6,13 @@ edition = "2024"
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
zlob = ["dep:zlob"]
|
||||
|
||||
[dependencies]
|
||||
smallvec = { workspace = true }
|
||||
zlob = { version = "1.2.8" }
|
||||
zlob = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::constraints::Constraint;
|
||||
use zlob::{ZlobFlags, has_wildcards};
|
||||
use crate::glob_detect::has_wildcards;
|
||||
|
||||
/// Parser configuration trait - allows different picker types to customize parsing
|
||||
pub trait ParserConfig {
|
||||
@@ -40,7 +40,7 @@ pub trait ParserConfig {
|
||||
/// Override this in configs where some wildcard characters are common
|
||||
/// in search text (e.g. grep mode where `?` and `[` appear in code).
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
has_wildcards(token, ZlobFlags::RECOMMENDED)
|
||||
has_wildcards(token)
|
||||
}
|
||||
|
||||
/// Custom constraint parsers for picker-specific needs
|
||||
@@ -87,7 +87,7 @@ impl ParserConfig for GrepConfig {
|
||||
/// - Contains `{…}` → brace expansion (e.g. `{src,lib}`)
|
||||
fn is_glob_pattern(&self, token: &str) -> bool {
|
||||
// Must contain at least one glob wildcard character
|
||||
if !has_wildcards(token, ZlobFlags::RECOMMENDED) {
|
||||
if !has_wildcards(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Glob wildcard detection — delegates to zlob when available, pure-Rust fallback otherwise.
|
||||
//!
|
||||
//! All call sites use a single function: `has_wildcards(text) -> bool`.
|
||||
//! When the `zlob` feature is enabled this calls `zlob::has_wildcards` with
|
||||
//! `ZlobFlags::RECOMMENDED`; without it we check for the same set of wildcard
|
||||
//! characters (`*`, `?`, `[`, `{`) in pure Rust.
|
||||
|
||||
#[cfg(feature = "zlob")]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
zlob::has_wildcards(s, zlob::ZlobFlags::RECOMMENDED)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "zlob"))]
|
||||
#[inline]
|
||||
pub fn has_wildcards(s: &str) -> bool {
|
||||
s.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
mod config;
|
||||
mod constraints;
|
||||
pub mod glob_detect;
|
||||
pub mod location;
|
||||
mod parser;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::ConstraintVec;
|
||||
use crate::config::ParserConfig;
|
||||
use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
|
||||
use crate::glob_detect::has_wildcards;
|
||||
use crate::location::{Location, parse_location};
|
||||
use zlob::{ZlobFlags, has_wildcards};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -184,7 +184,7 @@ fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constr
|
||||
// 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) {
|
||||
if !has_wildcards(ext_part) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ fn parse_token_without_negation<'a, C: ParserConfig>(
|
||||
// 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) {
|
||||
if !has_wildcards(ext_part) {
|
||||
return Some(constraint);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-19
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 20
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 06
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
@@ -83,12 +83,17 @@ LAZY.NVIM
|
||||
{
|
||||
"fz",
|
||||
function() require('fff').live_grep({
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
grep = {
|
||||
modes = { 'fuzzy', 'plain' }
|
||||
}
|
||||
}) end,
|
||||
desc = 'Live fffuzy grep',
|
||||
}
|
||||
},
|
||||
{
|
||||
"fc",
|
||||
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
|
||||
desc = 'Search current word',
|
||||
},
|
||||
}
|
||||
}
|
||||
<
|
||||
@@ -144,6 +149,10 @@ all available options:
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to false to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
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)
|
||||
@@ -159,6 +168,7 @@ all available options:
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both', -- the cursorlineopt used for lines in grep file previews, see :h cursorlineopt
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
@@ -179,7 +189,7 @@ all available options:
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
toggle_grep_regex = '<S-Tab>',
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
@@ -192,16 +202,15 @@ all available options:
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
cursor = 'CursorLine', -- Falls back to 'Visual' if CursorLine is not defined
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -230,9 +239,10 @@ all available options:
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
@@ -243,8 +253,8 @@ all available options:
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
@@ -260,15 +270,19 @@ all available options:
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- Live grep search configuration
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
<
|
||||
|
||||
|
||||
@@ -278,8 +292,7 @@ KEY FEATURES ~
|
||||
AVAILABLE METHODS
|
||||
|
||||
>lua
|
||||
require('fff').find_files() -- Find files in current directory
|
||||
require('fff').find_in_git_root() -- Find files in the current git repository
|
||||
require('fff').find_files() -- Find files in current repositro
|
||||
require('fff').scan_files() -- Trigger rescan of files in the current directory
|
||||
require('fff').refresh_git_status() -- Refresh git status for the active file lock
|
||||
require('fff').find_files_in_dir(path) -- Find files in a specific directory
|
||||
|
||||
+11
-10
@@ -41,7 +41,7 @@ local function detect_combo_item(items, file_picker, combo_boost_score_multiplie
|
||||
end
|
||||
|
||||
local function create_header_text(combo_count, win_width, disable_combo_display)
|
||||
local combo_text = nil
|
||||
local combo_text
|
||||
if disable_combo_display then
|
||||
combo_text = LAST_MATCH_TEXT_FORMAT
|
||||
else
|
||||
@@ -57,32 +57,32 @@ end
|
||||
|
||||
local function apply_header_highlights(buf, ns_id, line_idx, text_len, border_hl)
|
||||
local config = require('fff.conf').get()
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, border_hl, line_idx - 1, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, { end_row = line_idx, end_col = 0, hl_group = border_hl })
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
config.hl.combo_header,
|
||||
line_idx - 1,
|
||||
LEFT_HEADER_PADDING,
|
||||
LEFT_HEADER_PADDING + text_len
|
||||
{ end_col = LEFT_HEADER_PADDING + text_len, hl_group = config.hl.combo_header }
|
||||
)
|
||||
end
|
||||
|
||||
local function get_or_create_overlay_buf(state_key)
|
||||
if not overlay_state[state_key] or not vim.api.nvim_buf_is_valid(overlay_state[state_key]) then
|
||||
---@diagnostic disable-next-line: assign-type-mismatch
|
||||
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
|
||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = overlay_state[state_key] })
|
||||
end
|
||||
return overlay_state[state_key]
|
||||
end
|
||||
|
||||
local function update_overlay_content(buf, content, border_hl)
|
||||
-- Batch all buffer operations together for performance
|
||||
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = buf })
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content })
|
||||
vim.api.nvim_buf_clear_namespace(buf, overlay_state.ns_id, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(buf, overlay_state.ns_id, border_hl, 0, 0, -1)
|
||||
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_extmark(buf, overlay_state.ns_id, 0, 0, { end_row = 1, end_col = 0, hl_group = border_hl })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
|
||||
end
|
||||
|
||||
local function position_overlay_window(state_key, buf, width, row, col)
|
||||
@@ -101,10 +101,11 @@ local function position_overlay_window(state_key, buf, width, row, col)
|
||||
if overlay_state[state_key] and vim.api.nvim_win_is_valid(overlay_state[state_key]) then
|
||||
vim.api.nvim_win_set_config(overlay_state[state_key], win_config)
|
||||
else
|
||||
---@diagnostic disable-next-line: assign-type-mismatch
|
||||
overlay_state[state_key] = vim.api.nvim_open_win(buf, false, win_config)
|
||||
end
|
||||
|
||||
vim.api.nvim_win_set_option(overlay_state[state_key], 'winhl', 'Normal:Normal')
|
||||
vim.api.nvim_set_option_value('winhighlight', 'Normal:Normal', { win = overlay_state[state_key] })
|
||||
end
|
||||
|
||||
local function update_overlays(list_win, combo_header_line, border_hl)
|
||||
|
||||
+122
-12
@@ -1,8 +1,82 @@
|
||||
local M = {}
|
||||
|
||||
--@class fff.conf.State
|
||||
--- @class FffLayoutConfig
|
||||
--- @field height number
|
||||
--- @field width number
|
||||
--- @field prompt_position string
|
||||
--- @field preview_position string
|
||||
--- @field preview_size number
|
||||
--- @field show_scrollbar boolean
|
||||
--- @field path_shorten_strategy string
|
||||
|
||||
--- @class FffPreviewConfig
|
||||
--- @field enabled boolean
|
||||
--- @field max_size number
|
||||
--- @field chunk_size number
|
||||
--- @field binary_file_threshold number
|
||||
--- @field imagemagick_info_format_str string
|
||||
--- @field line_numbers boolean
|
||||
--- @field cursorlineopt string
|
||||
--- @field wrap_lines boolean
|
||||
--- @field filetypes table<string, table>
|
||||
|
||||
--- @class FffKeymapsConfig
|
||||
--- @field close string
|
||||
--- @field select string
|
||||
--- @field select_split string
|
||||
--- @field select_vsplit string
|
||||
--- @field select_tab string
|
||||
--- @field move_up string|string[]
|
||||
--- @field move_down string|string[]
|
||||
--- @field preview_scroll_up string
|
||||
--- @field preview_scroll_down string
|
||||
--- @field toggle_debug string
|
||||
--- @field cycle_grep_modes string
|
||||
--- @field cycle_previous_query string
|
||||
--- @field toggle_select string
|
||||
--- @field send_to_quickfix string
|
||||
--- @field focus_list string
|
||||
--- @field focus_preview string
|
||||
|
||||
--- @class FffFrecencyConfig
|
||||
--- @field enabled boolean
|
||||
--- @field db_path string
|
||||
|
||||
--- @class FffHistoryConfig
|
||||
--- @field enabled boolean
|
||||
--- @field db_path string
|
||||
--- @field min_combo_count number
|
||||
--- @field combo_boost_score_multiplier number
|
||||
|
||||
--- @class FffGrepConfig
|
||||
--- @field max_file_size number
|
||||
--- @field max_matches_per_file number
|
||||
--- @field smart_case boolean
|
||||
--- @field time_budget_ms number
|
||||
--- @field modes string[]
|
||||
|
||||
--- @class FffConfig
|
||||
--- @field base_path string
|
||||
--- @field prompt string
|
||||
--- @field title string
|
||||
--- @field max_results number
|
||||
--- @field max_threads number
|
||||
--- @field lazy_sync boolean
|
||||
--- @field layout FffLayoutConfig
|
||||
--- @field preview FffPreviewConfig
|
||||
--- @field keymaps FffKeymapsConfig
|
||||
--- @field hl table<string, string>
|
||||
--- @field frecency FffFrecencyConfig
|
||||
--- @field history FffHistoryConfig
|
||||
--- @field git table
|
||||
--- @field debug table
|
||||
--- @field logging table
|
||||
--- @field file_picker table
|
||||
--- @field grep FffGrepConfig
|
||||
|
||||
---@class fff.conf.State
|
||||
local state = {
|
||||
---@type table | nil
|
||||
---@type FffConfig|nil
|
||||
config = nil,
|
||||
}
|
||||
|
||||
@@ -99,6 +173,19 @@ local function handle_deprecated_config(user_config)
|
||||
return migrated_config
|
||||
end
|
||||
|
||||
---@param name table list of highlight groups to choose from
|
||||
---@return string one of the provided groups
|
||||
local function fallback_hl(name)
|
||||
local resolved_hl
|
||||
for _, hl in ipairs(name) do
|
||||
local resolved_group = vim.api.nvim_get_hl(0, { name = hl })
|
||||
|
||||
if not vim.tbl_isempty(resolved_group) then resolved_hl = hl end
|
||||
end
|
||||
|
||||
return resolved_hl or name[#name]
|
||||
end
|
||||
|
||||
local function init()
|
||||
local config = vim.g.fff or {}
|
||||
local default_config = {
|
||||
@@ -107,14 +194,24 @@ local function init()
|
||||
title = 'FFFiles',
|
||||
max_results = 100,
|
||||
max_threads = 4,
|
||||
lazy_sync = true, -- set to false if you want file indexing to start on open
|
||||
layout = {
|
||||
height = 0.8,
|
||||
width = 0.8,
|
||||
prompt_position = 'bottom', -- or 'top'
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
flex = { -- set to nil to disable flex layout
|
||||
size = 130, -- column threshold: if screen width >= size, use preview_position; otherwise use wrap
|
||||
wrap = 'top', -- position to use when screen is narrower than size
|
||||
},
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
path_shorten_strategy = 'middle_number', -- or 'middle', 'end'
|
||||
-- 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,
|
||||
@@ -123,6 +220,7 @@ local function init()
|
||||
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
|
||||
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
|
||||
line_numbers = false,
|
||||
cursorlineopt = 'both',
|
||||
wrap_lines = false,
|
||||
filetypes = {
|
||||
svg = { wrap_lines = true },
|
||||
@@ -136,31 +234,35 @@ local function init()
|
||||
select_split = '<C-s>',
|
||||
select_vsplit = '<C-v>',
|
||||
select_tab = '<C-t>',
|
||||
-- you can assign multiple keys to any action
|
||||
move_up = { '<Up>', '<C-p>' },
|
||||
move_down = { '<Down>', '<C-n>' },
|
||||
preview_scroll_up = '<C-u>',
|
||||
preview_scroll_down = '<C-d>',
|
||||
toggle_debug = '<F2>',
|
||||
-- grep mode: cycle between plain text, regex, and fuzzy search
|
||||
cycle_grep_modes = '<S-Tab>',
|
||||
-- goes to the previous query in history
|
||||
cycle_previous_query = '<C-Up>',
|
||||
-- multi-select keymaps for quickfix
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
toggle_grep_regex = '<S-Tab>',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
normal = 'Normal',
|
||||
cursor = 'CursorLine',
|
||||
matched = 'IncSearch',
|
||||
title = 'Title',
|
||||
prompt = 'Question',
|
||||
active_file = 'Visual',
|
||||
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
|
||||
frecency = 'Number',
|
||||
debug = 'Comment',
|
||||
combo_header = 'Number',
|
||||
scrollbar = 'Comment',
|
||||
directory_path = 'Comment', -- Highlight for directory path in file list
|
||||
directory_path = 'Comment',
|
||||
-- Multi-select highlights
|
||||
selected = 'FFFSelected',
|
||||
selected_active = 'FFFSelectedActive',
|
||||
@@ -187,23 +289,26 @@ local function init()
|
||||
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
|
||||
-- Grep highlights
|
||||
grep_match = 'IncSearch', -- Highlight for matched text in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location in grep results
|
||||
grep_line_number = 'LineNr', -- Highlight for :line:col location
|
||||
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
|
||||
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off (plain mode)
|
||||
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
|
||||
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
|
||||
-- Cross-mode suggestion highlights
|
||||
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
|
||||
},
|
||||
-- Store file open frecency
|
||||
frecency = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
|
||||
},
|
||||
-- Store successfully opened queries with respective matches
|
||||
history = {
|
||||
enabled = true,
|
||||
db_path = vim.fn.stdpath('data') .. '/fff_queries',
|
||||
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
|
||||
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
|
||||
},
|
||||
-- Git integration
|
||||
git = {
|
||||
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
|
||||
},
|
||||
@@ -217,9 +322,14 @@ local function init()
|
||||
log_file = vim.fn.stdpath('log') .. '/fff.log',
|
||||
log_level = 'info',
|
||||
},
|
||||
-- find_files settings
|
||||
file_picker = {
|
||||
current_file_label = '(current)',
|
||||
},
|
||||
-- grep settings
|
||||
grep = {
|
||||
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
|
||||
max_matches_per_file = 100, -- Maximum matches per file
|
||||
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
|
||||
smart_case = true, -- Case-insensitive unless query has uppercase
|
||||
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
|
||||
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
|
||||
@@ -233,10 +343,10 @@ local function init()
|
||||
end
|
||||
|
||||
--- Setup the file picker with the given configuration
|
||||
--- @param config table Configuration options
|
||||
--- @param config FffConfig Configuration options
|
||||
function M.setup(config) vim.g.fff = config end
|
||||
|
||||
--- @return table the fff configuration
|
||||
--- @return FffConfig the fff configuration
|
||||
function M.get()
|
||||
if not state.config then init() end
|
||||
return state.config
|
||||
|
||||
@@ -65,12 +65,6 @@ local function setup_global_autocmds(config)
|
||||
end,
|
||||
desc = 'Automatically sync FFF directory changes',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd('VimLeavePre', {
|
||||
group = group,
|
||||
callback = function() pcall(fuzzy.cleanup_file_picker) end,
|
||||
desc = 'Cleanup FFF background threads on Neovim exit',
|
||||
})
|
||||
end
|
||||
|
||||
--- @return boolean
|
||||
|
||||
+97
-14
@@ -25,7 +25,26 @@ end
|
||||
local function binary_exists(plugin_dir)
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
local stat = vim.uv.fs_stat(binary_path)
|
||||
return stat and stat.type == 'file'
|
||||
if stat and stat.type == 'file' then return true end
|
||||
|
||||
-- On Windows the rename over a loaded DLL fails, so a verified binary may be
|
||||
-- left at binary_path .. '.tmp'. Promote it now that the old session is gone.
|
||||
local tmp_path = binary_path .. '.tmp'
|
||||
local tmp_stat = vim.uv.fs_stat(tmp_path)
|
||||
if tmp_stat and tmp_stat.type == 'file' then
|
||||
-- Verify the .tmp is a valid library before promoting it, in case the
|
||||
-- process was killed between the loadlib check and the rename attempt
|
||||
-- during a previous download, leaving a corrupt or partial .tmp on disk.
|
||||
local loader = package.loadlib(tmp_path, 'luaopen_fff_nvim')
|
||||
if not loader then
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
return false
|
||||
end
|
||||
local ok = vim.uv.fs_rename(tmp_path, binary_path)
|
||||
return ok ~= nil
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function download_file(url, output_path, opts, callback)
|
||||
@@ -53,6 +72,12 @@ local function download_file(url, output_path, opts, callback)
|
||||
table.insert(curl_args, opts.proxy)
|
||||
end
|
||||
|
||||
if opts.extra_curl_args then
|
||||
for _, arg in ipairs(opts.extra_curl_args) do
|
||||
table.insert(curl_args, arg)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(curl_args, url)
|
||||
vim.system(curl_args, {}, function(result)
|
||||
if result.code ~= 0 then
|
||||
@@ -71,31 +96,66 @@ local function download_from_github(version, binary_path, opts, callback)
|
||||
local extension = system.get_lib_extension()
|
||||
local binary_name = triple .. '.' .. extension
|
||||
local url = string.format('https://github.com/%s/releases/download/%s/%s', GITHUB_REPO, version, binary_name)
|
||||
|
||||
vim.schedule(function()
|
||||
vim.notify(string.format('Downloading fff.nvim binary for ' .. version), vim.log.levels.INFO)
|
||||
vim.notify(string.format('Do not open fff until you see a success notification.'), vim.log.levels.WARN)
|
||||
end)
|
||||
|
||||
download_file(url, binary_path, {
|
||||
-- Download to a temp path first so we can validate before replacing the live binary.
|
||||
-- If we wrote directly to binary_path and the current process already has the old
|
||||
-- library loaded, package.loadlib() on the same path returns the *cached* handle —
|
||||
-- meaning a truncated download would pass validation silently.
|
||||
-- Using a distinct temp path forces dlopen to load the new file for real.
|
||||
local tmp_path = binary_path .. '.tmp'
|
||||
|
||||
download_file(url, tmp_path, {
|
||||
proxy = opts.proxy,
|
||||
extra_curl_args = opts.extra_curl_args,
|
||||
}, function(success, err)
|
||||
if not success then
|
||||
vim.uv.fanoushkas_unlink(tmp_path)
|
||||
callback(false, err)
|
||||
return
|
||||
end
|
||||
|
||||
-- Verify the binary can be loaded
|
||||
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
|
||||
vim.schedule(function()
|
||||
-- Validate the downloaded binary by actually loading it (temp path is not yet
|
||||
-- loaded by this process, so dlopen loads the new file for real and catches
|
||||
-- truncated or corrupt downloads).
|
||||
-- Note: package.loadlib returns (nil, error_string) on failure rather than throwing.
|
||||
local loader, load_err = package.loadlib(tmp_path, 'luaopen_fff_nvim')
|
||||
|
||||
if not ok then
|
||||
vim.uv.fs_unlink(binary_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
|
||||
return
|
||||
end
|
||||
if not loader then
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (load_err or 'unknown error'))
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function() vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO) end)
|
||||
callback(true, nil)
|
||||
-- Atomically replace the live binary only after successful validation.
|
||||
-- On Windows the old .dll may be locked by the current process, so rename can
|
||||
-- fail if fff is already loaded. In that case, leave the verified .tmp on disk
|
||||
-- so the next Neovim start can pick it up automatically.
|
||||
local rename_ok, rename_err = vim.uv.fs_rename(tmp_path, binary_path)
|
||||
if not rename_ok then
|
||||
if vim.uv.os_uname().sysname:lower():match('windows') then
|
||||
vim.notify(
|
||||
'fff.nvim binary downloaded to '
|
||||
.. tmp_path
|
||||
.. '.\nThe live binary is locked by the current session — please restart Neovim to apply the update.',
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
callback(true, nil)
|
||||
else
|
||||
vim.uv.fs_unlink(tmp_path)
|
||||
callback(false, 'Failed to install binary: ' .. (rename_err or 'unknown error'))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO)
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -131,7 +191,11 @@ function M.download_binary(callback)
|
||||
if callback then
|
||||
callback(false, err)
|
||||
else
|
||||
error('Failed to download fff.nvim binary: ' .. (err or 'unknown error'))
|
||||
vim.schedule(
|
||||
function()
|
||||
vim.notify('Failed to download fff.nvim binary: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
|
||||
end
|
||||
)
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -160,8 +224,14 @@ function M.build_binary(callback)
|
||||
end
|
||||
|
||||
function M.download_or_build_binary()
|
||||
local done = false
|
||||
local fatal_error = nil
|
||||
|
||||
M.ensure_downloaded({ force = true }, function(download_success, download_error)
|
||||
if download_success then return end
|
||||
if download_success then
|
||||
done = true
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(
|
||||
function()
|
||||
@@ -174,12 +244,25 @@ function M.download_or_build_binary()
|
||||
|
||||
M.build_binary(function(build_success, build_error)
|
||||
if not build_success then
|
||||
error('Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error'))
|
||||
fatal_error = 'Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error')
|
||||
else
|
||||
vim.schedule(function() vim.notify('fff.nvim binary built successfully!', vim.log.levels.INFO) end)
|
||||
end
|
||||
done = true
|
||||
end)
|
||||
end)
|
||||
|
||||
-- Block the caller (and keep the Neovim event loop alive) until the entire
|
||||
-- download-or-build chain finishes. This is critical for lazy.nvim build
|
||||
-- hooks: lazy returns from the hook immediately after this function returns,
|
||||
-- and if Neovim exits before the final rename(tmp → libfff_nvim.{dylib,so,dll})
|
||||
-- executes, the binary is never written to disk. vim.wait pumps the event
|
||||
-- loop so all vim.system / vim.schedule callbacks can fire.
|
||||
local timeout_ms = 1000 * 60 * 2 -- 2 minutes
|
||||
local ok, wait_err = vim.wait(timeout_ms, function() return done end, 100)
|
||||
if not ok and wait_err == -2 then error('fff.nvim: download_or_build_binary timed out') end
|
||||
|
||||
if fatal_error then error(fatal_error) end
|
||||
end
|
||||
|
||||
function M.get_binary_path()
|
||||
|
||||
@@ -44,7 +44,7 @@ function M.get_directory_icon(dirname)
|
||||
end
|
||||
elseif M.provider_name == 'mini.icons' then
|
||||
if M.provider.get then
|
||||
local icon, hl, is_default = M.provider.get('directory', basename)
|
||||
local icon, hl, _ = M.provider.get('directory', basename)
|
||||
if icon and icon ~= '' and hl then return icon, hl end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,11 +25,11 @@ local function reserve_image_buffer_space(bufnr, metadata_lines_count)
|
||||
table.insert(buffer_lines, '')
|
||||
end
|
||||
|
||||
local was_modifiable = vim.api.nvim_buf_get_option(bufnr, 'modifiable')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', was_modifiable)
|
||||
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
|
||||
|
||||
return metadata_lines_count or 2
|
||||
end
|
||||
@@ -44,9 +44,9 @@ local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
|
||||
metadata_lines[i] = info_lines[i] or ''
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function identify_image_lines_async(file_path, bufnr, callback)
|
||||
@@ -144,13 +144,12 @@ end
|
||||
--- Fully asynchronous
|
||||
--- @param file_path string Path to the image file
|
||||
--- @param bufnr number Buffer number to display in
|
||||
--- @param max_width number Maximum width in characters
|
||||
--- @param max_height number Maximum height in characters
|
||||
--- @return boolean
|
||||
function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
max_width = max_width or 80
|
||||
max_height = max_height or 24
|
||||
vim.api.nvim_buf_set_option(bufnr, 'number', false)
|
||||
function M.display_image(file_path, bufnr)
|
||||
local wins = vim.fn.win_findbuf(bufnr)
|
||||
for _, win in ipairs(wins) do
|
||||
vim.api.nvim_set_option_value('number', false, { win = win })
|
||||
end
|
||||
|
||||
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
|
||||
local image_content_starts_at_line = reserved_metadata_lines + 1
|
||||
@@ -167,9 +166,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
'',
|
||||
'snacks.nvim plugin is not installed or not available.',
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -180,9 +179,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
'Terminal does not support image preview.',
|
||||
'Please use a terminal that supports images, such as Kitty, Wezterm or Alacritty.',
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -193,9 +192,9 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
'File format is not supported for image preview.',
|
||||
'File: ' .. vim.fn.fnamemodify(file_path, ':t'),
|
||||
}
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ end
|
||||
--- @param max_threads number|nil Maximum number of threads to use
|
||||
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
|
||||
--- @param page_index number Page index (0-based: 0, 1, 2, ...)
|
||||
--- @param page_size number Items per page
|
||||
--- @param page_size number|nil Items per page (nil uses config default)
|
||||
--- @return table List of matching files
|
||||
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
|
||||
local config = require('fff.conf').get()
|
||||
|
||||
@@ -5,48 +5,22 @@ local location_utils = require('fff.location_utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Additional fallback for certain ambiguous filetypes which vim.filetype.match is not handling correctly
|
||||
local function get_fixed_filetype_detection(extension)
|
||||
local extension_map = {
|
||||
ts = 'typescript',
|
||||
tex = 'latex',
|
||||
md = 'markdown',
|
||||
txt = 'text',
|
||||
}
|
||||
|
||||
return extension_map[extension]
|
||||
end
|
||||
|
||||
local function detect_filetype(file_path)
|
||||
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
|
||||
if has_plenary then
|
||||
local detected = plenary_filetype.detect(file_path)
|
||||
if detected and detected ~= '' then return detected end
|
||||
end
|
||||
|
||||
local builtin_filetype = vim.filetype.match({ filename = file_path })
|
||||
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
|
||||
|
||||
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
return get_fixed_filetype_detection(extension)
|
||||
end
|
||||
|
||||
local function set_buffer_lines(bufnr, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function append_buffer_lines(bufnr, lines)
|
||||
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
|
||||
if not lines or #lines == 0 then return end
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
local current_lines = vim.api.nvim_buf_line_count(bufnr)
|
||||
vim.api.nvim_buf_set_lines(bufnr, current_lines, current_lines, false, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
end
|
||||
|
||||
local function find_existing_buffer(file_path)
|
||||
@@ -183,7 +157,7 @@ end
|
||||
-- Forward declaration for ensure_content_loaded_async (used in read_file_streaming_async callback)
|
||||
local ensure_content_loaded_async
|
||||
|
||||
local function read_file_streaming_async(file_path, bufnr, callback)
|
||||
local function read_file_streaming_async(file_path, callback)
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
init_dynamic_loading_async(file_path, function(success, error_msg)
|
||||
@@ -298,8 +272,8 @@ local function link_buffer_content(source_bufnr, target_bufnr)
|
||||
local lines = vim.api.nvim_buf_get_lines(source_bufnr, 0, -1, false)
|
||||
set_buffer_lines(target_bufnr, lines)
|
||||
|
||||
local source_ft = vim.api.nvim_buf_get_option(source_bufnr, 'filetype')
|
||||
if source_ft ~= '' then vim.api.nvim_buf_set_option(target_bufnr, 'filetype', source_ft) end
|
||||
local source_ft = vim.api.nvim_get_option_value('filetype', { buf = source_bufnr })
|
||||
if source_ft ~= '' then vim.api.nvim_set_option_value('filetype', source_ft, { buf = target_bufnr }) end
|
||||
|
||||
M.state.has_more_content = false
|
||||
M.state.total_file_lines = #lines
|
||||
@@ -322,7 +296,7 @@ M.state = {
|
||||
loading_chunk_size = 1000,
|
||||
is_loading = false,
|
||||
has_more_content = true,
|
||||
file_handle = nil,
|
||||
file_handle = nil, ---@type uv.uv_fs_t|nil
|
||||
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
|
||||
location = nil, -- Current location data for highlighting
|
||||
location_namespace = nil, -- Namespace for location highlighting
|
||||
@@ -341,9 +315,8 @@ end
|
||||
|
||||
--- Check if file is too big for initial preview (inspired by snacks.nvim)
|
||||
--- @param file_path string Path to the file
|
||||
--- @param bufnr number|nil Buffer number to check (unused with dynamic loading)
|
||||
--- @return boolean True if file is too big for initial preview
|
||||
function M.is_big_file(file_path, bufnr)
|
||||
function M.is_big_file(file_path)
|
||||
-- Only check file size for early detection - no line limits with dynamic loading
|
||||
local stat = vim.uv.fs_stat(file_path)
|
||||
if stat and stat.size > M.config.max_size then return true end
|
||||
@@ -368,7 +341,7 @@ function M.get_file_info(file_path)
|
||||
}
|
||||
|
||||
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
info.filetype = detect_filetype(file_path) or 'text'
|
||||
info.filetype = utils.detect_filetype(file_path) or 'text'
|
||||
info.size_formatted = utils.format_file_size(info.size)
|
||||
info.modified_formatted = os.date('%Y-%m-%d %H:%M:%S', info.modified)
|
||||
info.accessed_formatted = os.date('%Y-%m-%d %H:%M:%S', info.accessed)
|
||||
@@ -379,7 +352,7 @@ end
|
||||
--- Create file info content without custom borders
|
||||
--- @param file table File information from search results
|
||||
--- @param info table File system information
|
||||
--- @param file_index number Index of the file in search results (for score lookup)
|
||||
--- @param file_index number|nil Index of the file in search results (for score lookup)
|
||||
--- @return table Lines for the file info content
|
||||
function M.create_file_info_content(file, info, file_index)
|
||||
local lines = {}
|
||||
@@ -484,7 +457,7 @@ end
|
||||
--- @return boolean Success status
|
||||
function M.preview_file(file_path, bufnr)
|
||||
-- Early size detection to prevent memory issues
|
||||
if M.is_big_file(file_path, bufnr) then
|
||||
if M.is_big_file(file_path) then
|
||||
local info = M.get_file_info(file_path)
|
||||
local lines = {
|
||||
'File too large for preview',
|
||||
@@ -511,10 +484,12 @@ function M.preview_file(file_path, bufnr)
|
||||
if success then
|
||||
local file_config = M.get_file_config(file_path)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
|
||||
end
|
||||
|
||||
M.state.scroll_offset = 0
|
||||
|
||||
@@ -532,7 +507,7 @@ function M.preview_file(file_path, bufnr)
|
||||
M.state.bufnr = bufnr
|
||||
local generation = M.state.preview_generation
|
||||
|
||||
read_file_streaming_async(file_path, bufnr, function(content, err, loading_more)
|
||||
read_file_streaming_async(file_path, function(content, err, loading_more)
|
||||
if M.state.preview_generation ~= generation then
|
||||
-- Preview moved on to a different file, discard
|
||||
cleanup_file_operation()
|
||||
@@ -554,11 +529,13 @@ function M.preview_file(file_path, bufnr)
|
||||
set_buffer_lines(bufnr, content)
|
||||
|
||||
local file_config = M.get_file_config(file_path)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'filetype', info.filetype)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', file_config.wrap_lines or M.config.wrap_lines)
|
||||
vim.api.nvim_set_option_value('filetype', info.filetype, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
vim.api.nvim_set_option_value('wrap', file_config.wrap_lines or M.config.wrap_lines, { win = M.state.winid })
|
||||
end
|
||||
|
||||
M.state.content_height = #content
|
||||
M.state.scroll_offset = 0
|
||||
@@ -586,9 +563,9 @@ function M.preview_binary_file(file_path, bufnr)
|
||||
local lines = {}
|
||||
|
||||
set_buffer_lines(bufnr, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'filetype', 'text')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_set_option_value('filetype', 'text', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
|
||||
if vim.fn.executable('file') == 1 then
|
||||
local cmd = { 'file', '-b', file_path }
|
||||
@@ -639,7 +616,7 @@ end
|
||||
function M.get_file_config(file_path)
|
||||
if not M.config or not M.config.filetypes then return {} end
|
||||
|
||||
local filetype = detect_filetype(file_path) or 'text'
|
||||
local filetype = utils.detect_filetype(file_path) or 'text'
|
||||
return M.config.filetypes[filetype] or {}
|
||||
end
|
||||
|
||||
@@ -655,6 +632,7 @@ function M.preview(file_path, bufnr, location, is_binary)
|
||||
M.state.preview_generation = M.state.preview_generation + 1
|
||||
|
||||
if M.state.file_handle then
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
M.state.file_handle:close()
|
||||
M.state.file_handle = nil
|
||||
end
|
||||
@@ -673,10 +651,7 @@ function M.preview(file_path, bufnr, location, is_binary)
|
||||
|
||||
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return false end
|
||||
|
||||
local win_width = vim.api.nvim_win_get_width(M.state.winid) - 2
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.winid) - 2
|
||||
|
||||
return image.display_image(file_path, bufnr, win_width, win_height)
|
||||
return image.display_image(file_path, bufnr)
|
||||
elseif is_binary then
|
||||
return M.preview_binary_file(file_path, bufnr)
|
||||
else
|
||||
@@ -754,10 +729,10 @@ function M.update_file_info_buffer(file, bufnr, file_index)
|
||||
end
|
||||
set_buffer_lines(bufnr, file_info_lines)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'readonly', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'wrap', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('readonly', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('wrap', false, { buf = bufnr })
|
||||
|
||||
return true
|
||||
end
|
||||
@@ -797,10 +772,10 @@ function M.clear_buffer(bufnr)
|
||||
|
||||
pcall(vim.treesitter.stop, bufnr)
|
||||
|
||||
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_option(bufnr, 'filetype', '')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'syntax', '')
|
||||
vim.api.nvim_buf_set_option(bufnr, 'buftype', 'nofile')
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('filetype', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('syntax', '', { buf = bufnr })
|
||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||
|
||||
set_buffer_lines(bufnr, {})
|
||||
end
|
||||
@@ -839,7 +814,6 @@ function M.apply_location_highlighting(bufnr)
|
||||
|
||||
if not M.state.location then return end
|
||||
|
||||
-- Apply highlighting
|
||||
location_utils.highlight_location(bufnr, M.state.location, M.state.location_namespace)
|
||||
|
||||
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
|
||||
|
||||
+45
-53
@@ -2,30 +2,6 @@
|
||||
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
|
||||
local M = {}
|
||||
|
||||
--- Render Context passed to renderer functions
|
||||
--- @class RenderContext
|
||||
--- @field config table User configuration from conf.get()
|
||||
--- @field items table[] Array of file items being rendered
|
||||
--- @field cursor number Current cursor position (1-based index into items)
|
||||
--- @field win_height number Window height in lines
|
||||
--- @field win_width number Window width in columns
|
||||
--- @field max_path_width number Maximum width for file paths
|
||||
--- @field debug_enabled boolean Whether debug mode is enabled (shows frecency scores)
|
||||
--- @field prompt_position string Prompt position: 'top' or 'bottom'
|
||||
--- @field has_combo boolean Whether combo boost is active
|
||||
--- @field combo_header_line string Formatted combo header line (if has_combo)
|
||||
--- @field combo_header_text_len number Length of combo header text (if has_combo)
|
||||
--- @field combo_item_index number Index of item with combo (usually 1)
|
||||
--- @field display_start number Start index for displayed items
|
||||
--- @field display_end number End index for displayed items
|
||||
--- @field iter_start number Iteration start (may differ from display_start for bottom prompt)
|
||||
--- @field iter_end number Iteration end (may differ from display_end for bottom prompt)
|
||||
--- @field iter_step number Iteration step (1 for top prompt, -1 for bottom prompt)
|
||||
--- @field format_file_display fun(item: table, max_width: number): string, string Helper function to format filename and dir path
|
||||
--- @field selected_files table<string, boolean> Map of selected file paths
|
||||
--- @field query string Current search query
|
||||
--- @field renderer table|nil Custom renderer (if provided via opts)
|
||||
|
||||
--- File Item structure from Rust
|
||||
--- @class FileItem
|
||||
--- @field path string Absolute file path
|
||||
@@ -37,28 +13,23 @@ local M = {}
|
||||
--- @field total_frecency_score number Total frecency score
|
||||
--- @field access_frecency_score number Access-based frecency score
|
||||
--- @field modification_frecency_score number Modification-based frecency score
|
||||
--- @field git_status number|nil Git status enum (if file is in git repo)
|
||||
|
||||
--- Renderer Interface:
|
||||
--- @field render_line fun(item: FileItem, ctx: RenderContext, item_idx: number): string[] Returns array of line strings
|
||||
--- @field apply_highlights fun(item: FileItem, ctx: RenderContext, item_idx: number, buf: number, ns_id: number, line_idx: number, line_content: string): nil Applies highlights to the rendered line
|
||||
--- @field git_status string|nil Git status string (e.g. 'modified', 'untracked') if file is in git repo
|
||||
--- internal:
|
||||
--- @field _has_group_header boolean Internal flag for render_line to indicate if this item has a combo header line (not from Rust)
|
||||
|
||||
--- Render a file item line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param ctx ListRenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @return string[] Array of line strings (1 or 2 lines if combo)
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local lines = {}
|
||||
|
||||
-- Check if this should have combo header (first item with combo boost)
|
||||
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
|
||||
|
||||
if has_combo then table.insert(lines, ctx.combo_header_line) end
|
||||
|
||||
-- Get icon
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
local icon, _ = icons.get_icon(item.name, item.extension, false)
|
||||
|
||||
-- Build frecency indicator (debug mode only)
|
||||
local frecency = ''
|
||||
@@ -100,7 +71,7 @@ end
|
||||
|
||||
--- Apply highlights to a rendered line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param ctx ListRenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @param buf number Buffer handle
|
||||
--- @param ns_id number Namespace ID
|
||||
@@ -126,7 +97,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = line_idx,
|
||||
hl_group = ctx.config.hl.active_file,
|
||||
hl_group = ctx.config.hl.cursor,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
@@ -135,7 +106,13 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
-- 2. Icon
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_hl = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
0,
|
||||
{ end_col = vim.fn.strdisplaywidth(icon), hl_group = icon_hl }
|
||||
)
|
||||
end
|
||||
|
||||
-- 3. Git text color (filename)
|
||||
@@ -143,15 +120,27 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
|
||||
if git_text_hl and git_text_hl ~= '' and not is_current_file then
|
||||
local filename_start = #icon + 1
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
filename_start,
|
||||
{ end_col = filename_start + #filename, hl_group = git_text_hl }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- 4. Frecency indicator
|
||||
if ctx.debug_enabled then
|
||||
local start_pos, end_pos = line_content:find('[⭐️🔥✨•]%d+')
|
||||
if start_pos then
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
|
||||
if start_pos and end_pos then
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
line_idx - 1,
|
||||
start_pos - 1,
|
||||
{ end_col = end_pos, hl_group = ctx.config.hl.frecency }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -161,22 +150,26 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
if icon then
|
||||
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
|
||||
end
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.directory_path or 'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
{ end_col = prefix_len + #dir_path, hl_group = ctx.config.hl.directory_path }
|
||||
)
|
||||
end
|
||||
|
||||
-- 6. Current file
|
||||
if is_current_file then
|
||||
if not is_cursor then vim.api.nvim_buf_add_highlight(buf, ns_id, 'Comment', line_idx - 1, 0, -1) end
|
||||
local virt_text_hl = is_cursor and ctx.config.hl.active_file or 'Comment'
|
||||
local hl
|
||||
if is_cursor then
|
||||
hl = ctx.config.hl.cursor
|
||||
else
|
||||
hl = 'Comment'
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text = { { ' ' .. ctx.config.file_picker.current_file_label, hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
@@ -187,10 +180,10 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
local border_hl
|
||||
|
||||
if is_cursor then
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
local base_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.active_file)), 'bg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor)), 'bg')
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
@@ -199,7 +192,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = ctx.config.hl.active_file
|
||||
border_hl = ctx.config.hl.cursor
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
@@ -215,7 +208,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
elseif is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = ctx.config.hl.active_file,
|
||||
sign_hl_group = ctx.config.hl.cursor,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
@@ -234,13 +227,12 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
|
||||
if ctx.query and ctx.query ~= '' then
|
||||
local match_start, match_end = string.find(line_content, ctx.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
vim.api.nvim_buf_set_extmark(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
{ end_col = match_end, hl_group = ctx.config.hl.matched or 'IncSearch' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -109,20 +109,20 @@ function M.setup_highlights()
|
||||
vim.cmd([[
|
||||
" Symbol highlights
|
||||
highlight default FFFGitStaged guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitDeleted guifg=#EF4444 ctermfg=1
|
||||
highlight default FFFGitRenamed guifg=#8B5CF6 ctermfg=5
|
||||
highlight default FFFGitUntracked guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitIgnored guifg=#4B5563 ctermfg=8
|
||||
|
||||
" Thin border highlights
|
||||
|
||||
" Thin border highlights
|
||||
highlight default FFFGitSignStaged guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
|
||||
highlight default FFFGitSignDeleted guifg=#EF4444 ctermfg=1
|
||||
highlight default FFFGitSignRenamed guifg=#8B5CF6 ctermfg=5
|
||||
highlight default FFFGitSignUntracked guifg=#10B981 ctermfg=2
|
||||
highlight default FFFGitSignIgnored guifg=#4B5563 ctermfg=8
|
||||
|
||||
|
||||
" Fallback to GitSigns highlights if they exist
|
||||
highlight default link FFFGitSignStaged GitSignsAdd
|
||||
highlight default link FFFGitSignModified GitSignsChange
|
||||
|
||||
@@ -6,27 +6,26 @@
|
||||
local M = {}
|
||||
|
||||
local file_renderer = require('fff.file_renderer')
|
||||
local tresitter_highlight = require('fff.treesitter_hl')
|
||||
|
||||
--- Build the file group header line using the same layout as file_renderer.
|
||||
--- Delegates to file_renderer.render_line (with combo disabled).
|
||||
---@param item table Grep match item (used for file metadata)
|
||||
---@param item FileItem Grep match
|
||||
---@param ctx table Render context
|
||||
---@return string The header line string
|
||||
local function build_group_header(item, ctx)
|
||||
-- file_renderer.render_line checks (item_idx == 1 and ctx.has_combo) for combo header.
|
||||
-- We pass item_idx=0 and disable has_combo to suppress combo logic entirely.
|
||||
local saved_has_combo = ctx.has_combo
|
||||
ctx.has_combo = false
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
local lines = file_renderer.render_line(item, ctx, 0)
|
||||
ctx.has_combo = saved_has_combo
|
||||
ctx.has_combo = false -- never has a combo in grep
|
||||
return lines[1]
|
||||
end
|
||||
|
||||
--- Apply highlights for a file group header line using file_renderer.
|
||||
--- Delegates to file_renderer.apply_highlights so all highlight groups
|
||||
--- (icon, filename, git text color, directory path, git sign) match exactly.
|
||||
---@param item table Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param item FileItem Grep match item
|
||||
---@param ctx ListRenderContext Render context
|
||||
---@param buf number Buffer handle
|
||||
---@param ns_id number Namespace id
|
||||
---@param row number 0-based row in buffer (header line)
|
||||
@@ -49,16 +48,14 @@ local function render_match_line(item, ctx)
|
||||
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
|
||||
local separator = ' '
|
||||
local raw_content = item.line_content or ''
|
||||
local leading_ws = #raw_content - #raw_content:match('^%s*(.*)')
|
||||
local content = vim.trim(raw_content)
|
||||
local content = raw_content
|
||||
|
||||
-- Indent + location + separator + content
|
||||
local indent = ' '
|
||||
-- Prefix is always ASCII so byte length == display width
|
||||
local prefix_display_w = #indent + #location + #separator
|
||||
local available = ctx.win_width - prefix_display_w - 2
|
||||
local was_truncated = false
|
||||
local content_display_w = vim.fn.strdisplaywidth(content)
|
||||
|
||||
if content_display_w > available and available > 3 then
|
||||
-- UTF-8 aware truncation: binary search for the character count that
|
||||
-- fits within the available display width (handles multi-byte and wide chars)
|
||||
@@ -73,15 +70,11 @@ local function render_match_line(item, ctx)
|
||||
end
|
||||
end
|
||||
content = vim.fn.strcharpart(content, 0, lo) .. '…'
|
||||
was_truncated = true
|
||||
end
|
||||
|
||||
local line = indent .. location .. separator .. content
|
||||
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
||||
|
||||
-- Store transient data on item for highlight pass
|
||||
item._leading_ws = leading_ws
|
||||
item._was_truncated = was_truncated
|
||||
item._match_indent = #indent
|
||||
item._content_offset = prefix_display_w -- byte offset where content starts in the line
|
||||
item._trimmed_content = content -- trimmed content string for treesitter parsing
|
||||
@@ -102,9 +95,6 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
|
||||
local is_cursor = item_idx == ctx.cursor
|
||||
local indent = item._match_indent or 1
|
||||
|
||||
-- 1. Cursor line highlight — use hl_group + hl_eol instead of line_hl_group
|
||||
-- so that higher-priority inline extmarks (IncSearch match ranges at 200)
|
||||
-- cleanly override both fg and bg on the cursor line.
|
||||
if is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
|
||||
end_col = 0,
|
||||
@@ -143,16 +133,16 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
|
||||
-- below IncSearch match ranges (200) so search matches take precedence.
|
||||
local content_start = sep_end
|
||||
if item._trimmed_content and item.name then
|
||||
local ts_hl = require('fff.treesitter_hl')
|
||||
-- Resolve language once per file group (cache on the render context)
|
||||
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
|
||||
local lang = ctx._ts_lang_cache[item.name]
|
||||
if lang == nil then
|
||||
lang = ts_hl.lang_from_filename(item.name) or false
|
||||
lang = tresitter_highlight.lang_from_filename(item.name) or false
|
||||
ctx._ts_lang_cache[item.name] = lang
|
||||
end
|
||||
|
||||
if lang then
|
||||
local highlights = ts_hl.get_line_highlights(item._trimmed_content, lang)
|
||||
local highlights = tresitter_highlight.get_line_highlights(item._trimmed_content, lang)
|
||||
for _, hl in ipairs(highlights) do
|
||||
local hl_start = content_start + hl.col
|
||||
local hl_end = content_start + hl.end_col
|
||||
@@ -171,16 +161,14 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
|
||||
-- Use extmarks with priority > cursor line (100) so IncSearch renders
|
||||
-- properly on the selected line instead of being overridden by CursorLine.
|
||||
if item.match_ranges then
|
||||
local leading_ws = item._leading_ws or 0
|
||||
for _, range in ipairs(item.match_ranges) do
|
||||
local raw_start = range[1] or 0
|
||||
local raw_end = range[2] or 0
|
||||
local adj_start = raw_start - leading_ws
|
||||
local adj_end = raw_end - leading_ws
|
||||
if adj_end > 0 then
|
||||
adj_start = math.max(0, adj_start)
|
||||
local hl_start = content_start + adj_start
|
||||
local hl_end = content_start + adj_end
|
||||
|
||||
if raw_end > 0 then
|
||||
raw_start = math.max(0, raw_start)
|
||||
local hl_start = content_start + raw_start
|
||||
local hl_end = content_start + raw_end
|
||||
if hl_start < #line_content and hl_end <= #line_content then
|
||||
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
|
||||
end_col = hl_end,
|
||||
@@ -208,11 +196,10 @@ end
|
||||
--- Render a single item's lines (called by list_renderer's generate_item_lines).
|
||||
--- Returns 2 lines [header, match] for the first match of a file group,
|
||||
--- or 1 line [match] for subsequent matches in the same file.
|
||||
---@param item table Grep match item
|
||||
---@param item FileItem Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param item_idx number 1-based item index
|
||||
---@return string[]
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
function M.render_line(item, ctx)
|
||||
-- Track file grouping across the render pass via ctx
|
||||
-- ctx._grep_last_file is reset each render (ctx is fresh per render_list call)
|
||||
local is_new_group = (item.path ~= ctx._grep_last_file)
|
||||
@@ -221,8 +208,8 @@ function M.render_line(item, ctx, item_idx)
|
||||
local match_line = render_match_line(item, ctx)
|
||||
|
||||
if is_new_group then
|
||||
local header_line = build_group_header(item, ctx)
|
||||
item._has_group_header = true
|
||||
local header_line = build_group_header(item, ctx)
|
||||
return { header_line, match_line }
|
||||
else
|
||||
item._has_group_header = false
|
||||
@@ -233,8 +220,8 @@ end
|
||||
--- Apply highlights for rendered lines (called by list_renderer's apply_all_highlights).
|
||||
--- line_idx is the 1-based index of the item's LAST line (the match line).
|
||||
--- If the item has a group header, it's at line_idx - 1.
|
||||
---@param item table Grep match item
|
||||
---@param ctx table Render context
|
||||
---@param item FileItem Grep match item
|
||||
---@param ctx ListRenderContext Render context
|
||||
---@param item_idx number 1-based item index
|
||||
---@param buf number Buffer handle
|
||||
---@param ns_id number Namespace id
|
||||
|
||||
@@ -12,6 +12,7 @@ local fuzzy = require('fff.fuzzy')
|
||||
---@field total_files number Total indexed files
|
||||
---@field filtered_file_count number Total searchable files after filtering
|
||||
---@field next_file_offset number File offset to pass for the next page (0 = no more results)
|
||||
---@field regex_fallback_error string|nil Error message if regex compilation failed and search fell back to literal
|
||||
|
||||
local last_result = nil
|
||||
|
||||
|
||||
+28
-21
@@ -15,7 +15,7 @@
|
||||
local M = {}
|
||||
|
||||
--- @class ListRenderContext
|
||||
--- @field config table User configuration
|
||||
--- @field config FffConfig User configuration
|
||||
--- @field items table[] Array of data items to render
|
||||
--- @field cursor number Current cursor position (1-based index into items)
|
||||
--- @field win_height number Window height in lines
|
||||
@@ -37,6 +37,7 @@ local M = {}
|
||||
--- @field selected_files table<string, boolean> Selected file paths set
|
||||
--- @field mode string|nil Current mode (nil or 'grep')
|
||||
--- @field format_file_display function Helper for formatting file display
|
||||
--- @field suggestion_source string|nil Active cross-mode suggestion source ('grep' or 'files')
|
||||
|
||||
--- @class ItemLineMapping
|
||||
--- @field first number First buffer line (1-based) this item occupies
|
||||
@@ -54,7 +55,7 @@ local M = {}
|
||||
--- When cross-mode suggestions are active, a suggestion banner is prepended
|
||||
--- (for top prompt) or appended (for bottom prompt) so it always appears
|
||||
--- above the suggestion items visually.
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param ctx table
|
||||
--- @return string[] lines Array of line strings
|
||||
--- @return table<number, ItemLineMapping> item_to_lines
|
||||
local function generate_item_lines(ctx)
|
||||
@@ -90,11 +91,9 @@ local function generate_item_lines(ctx)
|
||||
-- Renderer returns 1+ lines: virtual headers first, content line last.
|
||||
-- This contract is shared by file_renderer (combo header) and
|
||||
-- grep_renderer (file group header).
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
local item_lines = renderer.render_line(item, ctx, i)
|
||||
|
||||
for _, line in ipairs(item_lines) do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
vim.list_extend(lines, item_lines)
|
||||
|
||||
local item_end_line = #lines
|
||||
local virtual_count = item_end_line - item_start_line -- 0 if single line, 1 if header + content
|
||||
@@ -120,7 +119,7 @@ end
|
||||
--- Adjusts all line indices in item_to_lines accordingly.
|
||||
--- @param lines string[] Lines array (mutated)
|
||||
--- @param item_to_lines table<number, ItemLineMapping> Mapping (mutated)
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param ctx table
|
||||
--- @return number padding_offset Number of empty lines prepended
|
||||
local function apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
if ctx.prompt_position ~= 'bottom' then return 0 end
|
||||
@@ -151,7 +150,7 @@ end
|
||||
--- never a virtual header line.
|
||||
--- @param lines string[]
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param ctx table
|
||||
--- @param list_buf number Buffer handle
|
||||
--- @param list_win number Window handle
|
||||
--- @param ns_id number Namespace id
|
||||
@@ -163,9 +162,9 @@ local function update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, lis
|
||||
if cursor_item then cursor_line = cursor_item.last end
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_option(list_buf, 'modifiable', true)
|
||||
vim.api.nvim_set_option_value('modifiable', true, { buf = list_buf })
|
||||
vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(list_buf, 'modifiable', false)
|
||||
vim.api.nvim_set_option_value('modifiable', false, { buf = list_buf })
|
||||
|
||||
vim.api.nvim_buf_clear_namespace(list_buf, ns_id, 0, -1)
|
||||
|
||||
@@ -180,7 +179,7 @@ end
|
||||
--- header highlights internally via the item._has_group_header flag.
|
||||
--- @param lines string[]
|
||||
--- @param item_to_lines table<number, ItemLineMapping>
|
||||
--- @param ctx ListRenderContext
|
||||
--- @param ctx table
|
||||
--- @param list_buf number
|
||||
--- @param ns_id number
|
||||
local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
|
||||
@@ -190,23 +189,24 @@ local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
local item = ctx.items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if not item_lines then goto continue end
|
||||
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
if item_lines then
|
||||
-- The content line is always the last line in the mapping
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
|
||||
if not line_content then goto continue end
|
||||
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
::continue::
|
||||
if line_content then
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Render the full item list into the buffer.
|
||||
--- This is the main entry point — replaces the inline rendering in picker_ui.
|
||||
---
|
||||
--- @param ctx ListRenderContext Render context built by picker_ui
|
||||
--- @param ctx table Render context built by picker_ui
|
||||
--- @param list_buf number List buffer handle
|
||||
--- @param list_win number List window handle
|
||||
--- @param ns_id number Highlight namespace
|
||||
@@ -225,7 +225,14 @@ function M.render(ctx, list_buf, list_win, ns_id)
|
||||
for i = 0, #lines - 1 do
|
||||
local line = lines[i + 1]
|
||||
if line and line:match('^%s+No results found') then
|
||||
pcall(vim.api.nvim_buf_add_highlight, list_buf, ns_id, suggestion_hl, i, 0, -1)
|
||||
pcall(
|
||||
vim.api.nvim_buf_set_extmark,
|
||||
list_buf,
|
||||
ns_id,
|
||||
i,
|
||||
0,
|
||||
{ end_row = i + 1, end_col = 0, hl_group = suggestion_hl }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+8
-14
@@ -18,14 +18,7 @@ function M.find_files(opts)
|
||||
end
|
||||
|
||||
--- Live grep: search file contents in the current directory
|
||||
--- @param opts? table Optional configuration overrides
|
||||
--- @param opts.cwd? string Custom working directory
|
||||
--- @param opts.title? string Window title (default: "Live Grep")
|
||||
--- @param opts.prompt? string Input prompt text (default: "grep> ")
|
||||
--- @param opts.layout? table Layout overrides
|
||||
--- @param opts.grep? table Grep-specific overrides {max_file_size, smart_case, max_matches_per_file, modes}
|
||||
--- @param opts.grep.modes? table Available search modes and their cycling order (default: {'plain', 'regex', 'fuzzy'})
|
||||
--- @param opts.query? string Initial search query to pre-fill
|
||||
--- @param opts? {cwd?: string, title?: string, prompt?: string, layout?: table, grep?: {max_file_size?: number, smart_case?: boolean, max_matches_per_file?: number, modes?: string[]}, query?: string} Optional configuration overrides
|
||||
function M.live_grep(opts)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if not picker_ok then
|
||||
@@ -38,17 +31,19 @@ function M.live_grep(opts)
|
||||
|
||||
local grep_config = vim.tbl_deep_extend('force', config.grep or {}, (opts and opts.grep) or {})
|
||||
|
||||
local picker_opts = vim.tbl_deep_extend('force', opts or {}, {
|
||||
title = (opts and opts.title) or 'Live Grep',
|
||||
local picker_opts = vim.tbl_deep_extend('force', {
|
||||
title = 'Live Grep',
|
||||
mode = 'grep',
|
||||
renderer = grep_renderer,
|
||||
grep_config = grep_config,
|
||||
query = opts and opts.query or nil,
|
||||
})
|
||||
query = '',
|
||||
}, opts or {})
|
||||
|
||||
picker_ui.open(picker_opts)
|
||||
end
|
||||
|
||||
--- Changes the directory indexed by the file picker to the git root and opens the file picker
|
||||
--- @deprecated Use `find_files` instead
|
||||
function M.find_in_git_root()
|
||||
local fuzzy = require('fff.core').ensure_initialized()
|
||||
local ok, git_root = pcall(fuzzy.get_git_root)
|
||||
@@ -212,7 +207,6 @@ end
|
||||
--- is found and we are about to inline open it
|
||||
--- @param open_cb function|nil Optional callback function to execute after opening the file
|
||||
function M.open_file_under_cursor(open_cb)
|
||||
local filename = vim.fn.expand('<cfile>')
|
||||
local full_path_with_suffix = vim.fn.expand('<cWORD>')
|
||||
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
@@ -221,7 +215,7 @@ function M.open_file_under_cursor(open_cb)
|
||||
return
|
||||
end
|
||||
|
||||
picker_ui.open_with_callback(full_path_with_suffix, function(files, metadata, location, get_file_score)
|
||||
picker_ui.open_with_callback(full_path_with_suffix, function(files, _, location)
|
||||
if #files == 1 or require('fff.file_picker').get_file_score(1).exact_match then
|
||||
if open_cb and type(open_cb) == 'function' then open_cb(files[1].path) end
|
||||
vim.api.nvim_command(string.format('e %s', vim.fn.fnameescape(files[1].path)))
|
||||
|
||||
+400
-320
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
-- rendering in a separate buffer to overflow the border
|
||||
if not scrollbar_exists then
|
||||
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(scrollbar_state.buf, 'bufhidden', 'wipe')
|
||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scrollbar_state.buf })
|
||||
|
||||
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
|
||||
relative = 'editor',
|
||||
@@ -52,7 +52,7 @@ function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
})
|
||||
|
||||
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
|
||||
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
|
||||
vim.api.nvim_set_option_value('winhighlight', scrollbar_hl, { win = scrollbar_state.win })
|
||||
|
||||
scrollbar_state.ever_shown = true
|
||||
end
|
||||
@@ -83,9 +83,9 @@ function M.render(layout, config, list_win, pagination, prompt_position)
|
||||
end
|
||||
end
|
||||
|
||||
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
|
||||
pcall(vim.api.nvim_set_option_value, 'modifiable', true, { buf = scrollbar_state.buf })
|
||||
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
|
||||
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', false)
|
||||
pcall(vim.api.nvim_set_option_value, 'modifiable', false, { buf = scrollbar_state.buf })
|
||||
|
||||
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
|
||||
if thumb_size > 0 then
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
--- Treesitter Highlight Extraction
|
||||
--- Extracts syntax highlights from a code string using treesitter.
|
||||
--- Uses a per-language scratch buffer pool to avoid repeated buffer creation.
|
||||
--- Results are returned as extmark-style tables { col, end_col, hl_group }.
|
||||
local utils = require('fff.utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Per-language scratch buffer cache
|
||||
@@ -33,11 +31,7 @@ end
|
||||
function M.lang_from_filename(filename)
|
||||
if not filename or filename == '' then return nil end
|
||||
|
||||
-- Use vim.filetype.match to get the filetype from the filename
|
||||
local ok, ft = pcall(vim.filetype.match, { filename = filename })
|
||||
if not ok or not ft then return nil end
|
||||
|
||||
-- Convert filetype to treesitter language
|
||||
local ft = utils.detect_filetype(filename) or 'text'
|
||||
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, ft)
|
||||
if not lang_ok or not lang then lang = ft end
|
||||
|
||||
@@ -83,7 +77,7 @@ function M.get_line_highlights(text, lang)
|
||||
local query_ok, query = pcall(vim.treesitter.query.get, tree_lang, 'highlights')
|
||||
if not query_ok or not query then return end
|
||||
|
||||
for capture, node, metadata in query:iter_captures(root, buf, 0, 1) do
|
||||
for capture, node, _ in query:iter_captures(root, buf, 0, 1) do
|
||||
local name = query.captures[capture]
|
||||
if name and name ~= 'spell' and name ~= 'conceal' then
|
||||
local start_row, start_col, end_row, end_col = node:range()
|
||||
|
||||
+30
-1
@@ -17,6 +17,34 @@ function M.format_file_size(size)
|
||||
end
|
||||
end
|
||||
|
||||
local function get_fixed_filetype_detection(extension)
|
||||
local extension_map = {
|
||||
ts = 'typescript',
|
||||
tex = 'latex',
|
||||
md = 'markdown',
|
||||
txt = 'text',
|
||||
}
|
||||
|
||||
return extension_map[extension]
|
||||
end
|
||||
|
||||
--- Detect filetype with various fallbacks
|
||||
--- @param file_path string the filetype
|
||||
--- @return string detected filetype
|
||||
function M.detect_filetype(file_path)
|
||||
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
|
||||
if has_plenary then
|
||||
local detected = plenary_filetype.detect(file_path, {})
|
||||
if detected and detected ~= '' then return detected end
|
||||
end
|
||||
|
||||
local builtin_filetype = vim.filetype.match({ filename = file_path })
|
||||
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
|
||||
|
||||
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
|
||||
return get_fixed_filetype_detection(extension)
|
||||
end
|
||||
|
||||
--- Safely resolve a config value that can be either a static value or a function
|
||||
--- @param config_value any The config value (can be function or static value)
|
||||
--- @param terminal_width number Terminal width for function calls
|
||||
@@ -24,7 +52,7 @@ end
|
||||
--- @param validator function Function to validate the result
|
||||
--- @param fallback any Fallback value if function fails or returns invalid value
|
||||
--- @param error_context string Context for error messages
|
||||
--- @return any The resolved and validated value
|
||||
--- @return number The resolved and validated value
|
||||
function M.resolve_config_value(config_value, terminal_width, terminal_height, validator, fallback, error_context)
|
||||
if type(config_value) == 'function' then
|
||||
local success, result = pcall(config_value, terminal_width, terminal_height)
|
||||
@@ -38,6 +66,7 @@ function M.resolve_config_value(config_value, terminal_width, terminal_height, v
|
||||
return fallback
|
||||
end
|
||||
else
|
||||
if config_value == nil or not validator(config_value) then return fallback end
|
||||
return config_value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,18 +11,24 @@ function M.get_triple()
|
||||
if os_name == 'darwin' then
|
||||
os_name = 'apple-darwin'
|
||||
elseif os_name == 'linux' then
|
||||
-- Detect if we're on musl or glibc
|
||||
local handle = io.popen('ldd --version 2>&1')
|
||||
if handle then
|
||||
local output = handle:read('*a')
|
||||
handle:close()
|
||||
if output and output:match('musl') then
|
||||
os_name = 'unknown-linux-musl'
|
||||
-- Detect Android/Termux before checking musl/glibc.
|
||||
-- Termux uses Bionic libc (not glibc or musl) and has no ldd.
|
||||
if os.getenv('TERMUX_VERSION') or os.getenv('ANDROID_ROOT') then
|
||||
os_name = 'linux-android'
|
||||
else
|
||||
-- Detect if we're on musl or glibc
|
||||
local handle = io.popen('ldd --version 2>&1')
|
||||
if handle then
|
||||
local output = handle:read('*a')
|
||||
handle:close()
|
||||
if output and output:match('musl') then
|
||||
os_name = 'unknown-linux-musl'
|
||||
else
|
||||
os_name = 'unknown-linux-gnu'
|
||||
end
|
||||
else
|
||||
os_name = 'unknown-linux-gnu'
|
||||
end
|
||||
else
|
||||
os_name = 'unknown-linux-gnu'
|
||||
end
|
||||
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
|
||||
os_name = 'pc-windows-msvc'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
---@diagnostic disable: undefined-field
|
||||
local fff_rust = require('fff.rust')
|
||||
|
||||
--- Wait for the scan to fully complete, handling the startup race where
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/test_lazy_async_bug.sh
|
||||
#
|
||||
# Shell-level proof of the async-exit bug in the original download_or_build_binary().
|
||||
#
|
||||
# When lazy.nvim's build hook returns, Neovim may exit moments later.
|
||||
# The old implementation fired vim.system subprocesses and returned immediately,
|
||||
# so those subprocesses (git → curl → sha → rename) were orphaned on exit and
|
||||
# the binary was never written to disk.
|
||||
#
|
||||
# The fix wraps the whole chain in vim.wait, keeping the event loop alive until
|
||||
# the rename completes.
|
||||
#
|
||||
# Usage: bash tests/test_lazy_async_bug.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export FFF_PLUGIN_ROOT="$PLUGIN_ROOT" # picked up by os.getenv() inside Lua
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) EXT=dylib ;;
|
||||
MINGW*|MSYS*|CYGWIN*) EXT=dll ;;
|
||||
*) EXT=so ;;
|
||||
esac
|
||||
BINARY="$PLUGIN_ROOT/target/release/libfff_nvim.$EXT"
|
||||
|
||||
passed=0; failed=0
|
||||
pass() { printf ' PASS %s\n' "$1"; (( passed += 1 )) || true; }
|
||||
fail() { printf ' FAIL %s\n' "$1" >&2; (( failed += 1 )) || true; }
|
||||
assert_file() { [[ -f $1 ]] && pass "$2" || fail "$2 (file missing: $1)"; }
|
||||
assert_no_file() { [[ ! -f $1 ]] && pass "$2" || fail "$2 (file unexpectedly present: $1)"; }
|
||||
|
||||
# ── Temp runners (cleaned up on exit) ────────────────────────────────────────
|
||||
RUNNERS=$(mktemp -d)
|
||||
trap 'rm -rf "$RUNNERS"' EXIT
|
||||
|
||||
# Runner A — simulates OLD build hook:
|
||||
# ensure_downloaded fires async vim.system calls, then returns.
|
||||
# os.exit(0) mimics Neovim exiting immediately after the hook returns —
|
||||
# the event loop never spins again, so the git/curl/rename callbacks die.
|
||||
cat >"$RUNNERS/async.lua" <<'LUA'
|
||||
vim.opt.runtimepath:prepend(os.getenv('FFF_PLUGIN_ROOT'))
|
||||
require('fff.download').ensure_downloaded(
|
||||
{ version = '3e9b865', force = true },
|
||||
function() end -- this callback is never reached
|
||||
)
|
||||
os.exit(0) -- immediate exit, same effect as lazy returning from the hook
|
||||
LUA
|
||||
|
||||
# Runner B — exercises the FIXED download_or_build_binary():
|
||||
# vim.wait spins the event loop until the rename lands on disk,
|
||||
# so the function only returns after the binary is present.
|
||||
cat >"$RUNNERS/blocking.lua" <<'LUA'
|
||||
vim.opt.runtimepath:prepend(os.getenv('FFF_PLUGIN_ROOT'))
|
||||
-- Blocks via vim.wait; only returns once the binary is on disk.
|
||||
require('fff.download').download_or_build_binary()
|
||||
LUA
|
||||
|
||||
printf '\n=== lazy.nvim async-exit bug ===\nbinary : %s\n\n' "$BINARY"
|
||||
|
||||
# ── Part 1: old async (broken) ────────────────────────────────────────────────
|
||||
echo '--- Part 1: OLD async behavior — fire subprocesses and exit ---'
|
||||
rm -f "$BINARY" "$BINARY.tmp"
|
||||
|
||||
nvim -l "$RUNNERS/async.lua" 2>/dev/null
|
||||
|
||||
# Give any orphaned subprocesses a full second to do whatever they can.
|
||||
# They finish running (git/curl), but the Neovim rename callback is dead,
|
||||
# so the binary never moves from .tmp → libfff_nvim.dylib.
|
||||
sleep 1
|
||||
assert_no_file "$BINARY" "binary absent — rename callback was killed with the process"
|
||||
|
||||
# ── Part 2: fixed blocking ────────────────────────────────────────────────────
|
||||
printf '\n--- Part 2: FIXED behavior --- vim.wait keeps event loop alive ---\n'
|
||||
rm -f "$BINARY" "$BINARY.tmp"
|
||||
|
||||
nvim -l "$RUNNERS/blocking.lua" # prints download progress to stdout
|
||||
|
||||
assert_file "$BINARY" "binary present — vim.wait held the process until rename succeeded"
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────────────────────
|
||||
printf '\n%d passed %d failed\n' "$passed" "$failed"
|
||||
(( failed == 0 ))
|
||||
Reference in New Issue
Block a user