Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
76b7318
Fix O(N) memory explosion in scoring path for large candidate pools
jojojames May 9, 2026
ff17a44
Implement match-position highlighting in the C module
jojojames May 9, 2026
8d08ec5
Implement support for fzf-async-max-line-length defcustom in async re…
jojojames May 9, 2026
f9a255d
Implement highlighting for 1-candidate and batch case
jojojames May 9, 2026
adb91f4
Expose cache variable
jojojames May 9, 2026
e3cadaf
Update architecture
jojojames May 9, 2026
347829e
Update binary ubuntu-latest
invalid-email-address May 9, 2026
304f046
Update binary windows-latest
invalid-email-address May 9, 2026
4940da9
Update binary macos-latest
invalid-email-address May 9, 2026
250341a
Fix c tests
jojojames May 9, 2026
bc8521e
Remove tests related to indices being returned
jojojames May 9, 2026
9373268
Remove advice
jojojames May 9, 2026
9ca2b35
Remove mention of advice
jojojames May 9, 2026
13765c8
Update readme
jojojames May 9, 2026
cdab0ac
Require 29
jojojames May 9, 2026
b72933c
Extract $PATH from emacs and use shell set by emacs
jojojames May 9, 2026
77e4bc6
Update binary ubuntu-latest
invalid-email-address May 9, 2026
fded535
Update binary windows-latest
invalid-email-address May 9, 2026
1220006
Update binary macos-latest
invalid-email-address May 9, 2026
e897fc3
Remove
jojojames May 9, 2026
53fe7d9
Update binary ubuntu-latest
invalid-email-address May 9, 2026
9327cc9
Update binary macos-latest
invalid-email-address May 9, 2026
4b5c999
Update binary windows-latest
invalid-email-address May 9, 2026
e781893
Try action again
jojojames May 9, 2026
eaeffe2
Update binaries for all platforms
invalid-email-address May 9, 2026
cba91ff
Add test message
jojojames May 10, 2026
9e37074
Update binaries for all platforms
invalid-email-address May 10, 2026
a77b52e
Add BSD
jojojames May 10, 2026
ffd36b7
Update binaries for all platforms
invalid-email-address May 10, 2026
3722ec9
Revert "Add test message"
jojojames May 10, 2026
6080d49
Update binaries for all platforms
invalid-email-address May 10, 2026
5095552
Add phase 1 cache
jojojames May 10, 2026
dc88f2b
Add phase 2 cache
jojojames May 10, 2026
02368af
Update binaries for all platforms
invalid-email-address May 10, 2026
e8c48f9
Build before test
jojojames May 10, 2026
cb8bb57
Set -D_POSIX_C_SOURCE=200809L for ctest
jojojames May 10, 2026
fb43f7b
Use gnu11 instead
jojojames May 10, 2026
4d564f1
Handle case mode and expose custom variable
jojojames May 10, 2026
0601dd7
Wipe text properties before adding them
jojojames May 10, 2026
0c86c0d
Restore chunked cands_top to bound reader allocations at 2 MB
jojojames May 10, 2026
34ab2a7
Keep [FILTERED](TOTAL) overlay in sync with what's displayed
jojojames May 10, 2026
f783ae5
Make C reads canonical: fzf-native-{batch,async}-* defcustoms
jojojames May 10, 2026
98913f5
Update binaries for all platforms
invalid-email-address May 10, 2026
e6e9379
Drop `t' sentinel from fzf-native-max-line-length; default is 256
jojojames May 10, 2026
32d3a35
Update binaries for all platforms
invalid-email-address May 10, 2026
32e9288
Update readme & architecture
jojojames May 10, 2026
6e409e8
Update gitignore
jojojames May 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 65 additions & 20 deletions .github/workflows/cmake-binaries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,52 +6,97 @@ on:
branches: ["develop"]
paths: ["**.c", "**.h"]

# Make sure to build each platform binary one-at-a-time.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
REPO: "dangduc/fzf-native"
SOURCE_BRANCH: ${{ github.ref }}
TARGET_BRANCH: ${{ github.ref }}
# Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.)
BUILD_TYPE: Release

jobs:
build:
# The CMake configure and build commands are platform agnostic and
# should work equally well on Windows or Mac. You can convert
# this to a matrix build if you need cross-platform coverage.
# See:
# https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]

steps:
- uses: actions/checkout@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v4

- name: Pull branch changes
run: |
git pull
- name: Clean stale binaries
shell: bash
run: rm -rf bin

- name: Configure CMake
# Configure CMake in a 'build' subdirectory.
run: cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}

- name: Build
# Build your program with the given configuration
run: cmake --build ${{github.workspace}}/build --verbose --config ${{env.BUILD_TYPE}}

- name: Upload binaries
uses: actions/upload-artifact@v4
with:
name: bin-${{ matrix.os }}
path: bin/
if-no-files-found: error
retention-days: 1

build-freebsd:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Clean stale binaries
run: rm -rf bin

- name: Build in FreeBSD VM
uses: vmactions/freebsd-vm@v1
with:
usesh: true
prepare: |
pkg install -y cmake
run: |
cmake -B build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}
cmake --build build --verbose --config ${{env.BUILD_TYPE}}

- name: Upload binaries
uses: actions/upload-artifact@v4
with:
name: bin-freebsd
path: bin/
if-no-files-found: error
retention-days: 1

commit:
needs: [build, build-freebsd]
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Download all binaries
uses: actions/download-artifact@v4
with:
path: bin-artifacts
pattern: bin-*
merge-multiple: true

- name: Stage binaries
run: |
mkdir -p bin
cp -R bin-artifacts/. bin/
rm -rf bin-artifacts

- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: github-actions
author_email: github-actions@github.com
message: Update binary ${{ matrix.os }}
add: "bin/**"
message: Update binaries for all platforms
add: "bin/**"
9 changes: 9 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,13 @@ jobs:
- run: make lint
- run: make ctest
if: runner.os != 'Windows'

- name: Clean stale binaries
shell: bash
run: rm -rf bin
- name: Configure CMake
run: cmake -B build -DCMAKE_BUILD_TYPE=Release
- name: Build native module
run: cmake --build build --config Release

- run: make test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ build
/.eask/
/dist/
fzf-native.log
fzf-native-pkg.el
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ emacs-asan:
.PHONY: ctest
ctest:
mkdir -p $(BUILD_DIR)
$(CC) -std=c11 -Wall -Wextra -O2 -I. -pthread \
$(CC) -std=gnu11 -Wall -Wextra -O2 -I. -pthread \
-o $(BUILD_DIR)/fzf-native-ctest fzf-native-ctest.c fzf.c
$(BUILD_DIR)/fzf-native-ctest

Expand Down
46 changes: 35 additions & 11 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ Clone / download this repository and modify your ~load-path~:
(add-to-list 'load-path (expand-file-name "/path/to/fzf-native/" user-emacs-directory))
#+end_src

*** use-package with :vc

#+begin_src emacs-lisp
; Configuration that uses pre-built dynamic module.
(use-package fzf-native
:vc (:url "https://github.com/dangduc/fzf-native" :rev :newest)
:config
(fzf-native-load-dyn))
#+end_src

*** Straight Examples

#+begin_src emacs-lisp
Expand All @@ -85,17 +95,6 @@ Clone / download this repository and modify your ~load-path~:
(fzf-native-load-dyn))
#+end_src

*** Multibyte character support

Work around the lib's lack of support for multibyte chars. Add this
advice if you want accurate indices for multibyte chars. Don't add
this advice if you want better run time performance or you don't need
accurate indices for multibyte chars.

#+begin_src emacs-lisp
(advice-add 'fzf-native-score :around #'fzf-native--fix-score-indices)
#+end_src

** Use Cases

[[https://github.com/jojojames/fussy][Fussy]]: ~fzf-native~ is used as
Expand All @@ -111,6 +110,31 @@ stream candidates from long-running shell commands (~find~, ~rg~,
[[https://github.com/jojojames/fzf-async/blob/HEAD/architecture.org][fzf-async's architecture overview]]
for the Elisp-side pipeline.

** Customization

The C module reads the following defcustoms via ~symbol-value~ at call
time. Higher-level packages (~fussy~, ~fzf-async~) keep their own
user-facing defcustoms and bridge their values onto these canonical
names — ~fussy~ via ~setq-local~ (synchronous, same-buffer call
pattern), ~fzf-async~ via ~:around~ advice on the C entry points
(timer-driven, cross-buffer). Set these directly only if you call
~fzf-native-score~ / ~fzf-native-score-all~ / ~fzf-native-async-*~
yourself; otherwise prefer the package-level knob.

| Variable | Default | Read by | Behavior |
|---------------------------------+---------+------------------------------------+---------------------------------------------|
| ~fzf-native-case-mode~ | ~smart~ | every scoring call (sync + async) | ~smart~ (lowercase = ignore, mixed = respect) / ~ignore~ / ~respect~ |
| ~fzf-native-batch-highlight~ | 25 | ~fzf-native-score~ / ~fzf-native-score-all~ | nil = off; positive N = apply ~completions-common-part~ face to top N candidates via ~fzf_get_positions~ in C. |
| ~fzf-native-async-highlight~ | 200 | ~fzf-native-async-candidates~ | nil = off; t = all returned candidates; positive N = top N. |
| ~fzf-native-max-line-length~ | 256 | ~fzf-native-async-start~ (once at session start) | nil = no limit; +N = drop lines longer than N; -N = truncate lines to N characters. |
| ~fzf-native-async-cache-size~ | 40 | ~fzf-native-async-start~ (once at session start) | LRU result-cache entries. Each entry stores top-K results plus the full matched-candidate index for one query, enabling exact-fresh hits and prefix-refinement hits. |

Highlighting runs entirely inside the C module: after scoring,
~fzf_get_positions~ produces matched byte offsets, which are merged
into runs and applied to the candidate string with ~put-text-property~
before strings are handed back to Emacs. No Elisp regex pass is
involved.

** Building the Native Libraries

#+begin_src bash
Expand Down
113 changes: 103 additions & 10 deletions architecture.org
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ process and must never block the main thread.

| Function | Args | Returns | Path |
|--------------------------------+--------------------------------------+------------------------+-------------|
| =fzf-native-score= | =(str query &optional slab)= | =(score idx idx ...)= | sync single |
| =fzf-native-score= | =(str query &optional slab)= | =(score)= | sync single |
| =fzf-native-score-all= | =(collection query &optional slab)= | sorted candidate vector | sync batch |
| =fzf-native-make-default-slab= | =()= | slab | (helper) |
| =fzf-native-make-slab= | =(size16 size32)= | slab | (helper) |
Expand Down Expand Up @@ -133,17 +133,22 @@ the fzf-native backend.
Elisp call: (fzf-native-score "foobar" "fb" slab)
┌──────────────────────────────────┐
│ C: fzf_native_score │
│ - copy_emacs_string("foobar") │
│ - fzf_parse_pattern("fb") │
│ - fzf_get_score(text, pat, slab)│
│ - free pattern │
│ - build (score idx idx ...) cons│
└──────────────────────────────────┘
┌──────────────────────────────────────────┐
│ C: fzf_native_score │
│ - copy_emacs_string("foobar") │
│ - fzf_parse_pattern("fb") │
│ - fzf_get_score(text, pat, slab) │
│ - if fussy-fzf-native-highlight non-nil │
│ and score > 0: │
│ - fzf_get_positions │
│ - put-text-property on args[0] with │
│ face=completions-common-part │
│ - free pattern │
│ - return (score) │
└──────────────────────────────────────────┘
Elisp value: (score idx0 idx1 ...)
Elisp value: (score) ; match indices not surfaced
#+end_src

** Notes
Expand All @@ -154,6 +159,12 @@ the fzf-native backend.
avoid that cost.
- Non-UTF-8 Emacs strings are coerced via =copy_emacs_string=, falling
back to =encode-coding-string= if =make_string= would signal.
- Highlighting is applied in C, not Elisp. The C module reads
=fussy-fzf-native-highlight= via =symbol-value= on every call; any
non-nil value enables highlighting (the cap concept used by
=fzf_native_score_all= does not apply to a single candidate). The
return value is =(score)= — match indices are no longer surfaced
to Elisp callers.

* Path 2 — sync batch (=fzf_native_score_all=)

Expand Down Expand Up @@ -287,6 +298,10 @@ ready."
│ score_count │
│ last_filtered │
│ last_total │
├─────────────────┤
│ cache │ ← LRU result cache, own mutex
│ head, tail │
│ count, max │
└─────────────────┘
#+end_src

Expand Down Expand Up @@ -391,6 +406,84 @@ are still pulled.
which serves as the "has any data arrived yet" signal but is not the
cross-thread publish signal.)

** Result cache

A per-session LRU cache sits between the dispatch path and the scoring
thread. Configured via the =fzf-async-cache-size= defcustom (read
once at session start; default 40 entries).

*Data model*:

#+begin_src
CacheEntry {
query : strdup'd filter string ← owned
pool_gen : s->count at time of scoring
top[K] : copy of top-K results published ← owned
m_idx : SharedIdx*, refcounted ← shared
parsed : fzf_pattern_t* (parsed query) ← owned
}

Cache {
head, tail : doubly-linked list (MRU at head)
count, max
mu : mutex
}
#+end_src

=SharedIdx= is a refcounted flexible-array struct of =uint32_t=
holding the *full set* of matched candidate indices for one scoring
run (not just the top-K). Lookup consumers retain in O(1) under the
cache mutex (atomic refcount bump — no memcpy); the last consumer
frees it.

*Dispatch flow* (=fzf_native_async_candidates=, main thread):

| Lookup result | Display | Scoring scheduled |
|----------------------+----------------------+-----------------------------------|
| Exact, pool_gen==now | Cached top-K | None (no work) |
| Exact, pool_gen<now | Cached top-K | Refine on m_idx + delta |
| Prefix hit | Prefix's top-K | Refine on prefix m_idx + delta |
| Miss | Current score_results | Full pool scoring |

*Subsumption rule* (cache_lookup_prefix). Q' subsumes Q if either
holds:

- *Byte-prefix* — Q' is a literal byte-prefix of Q and neither
contains =|=. Captures: extending a term, adding AND terms at the
end, adding negations/anchors at the end.
- *Term-set* — both queries parse to non-OR patterns (no term-set
with >1 term) and every term-set in Q' has an equivalent term-set
in Q. Captures: adding an AND term in non-prefix position
(=fo= → =x fo=), term reordering (=foo bar= → =bar foo=),
non-prefix negation (=fo= → =!x fo=).

OR queries (containing =|=) are excluded from prefix lookups in both
directions — adding an OR alternate widens results unpredictably, so
prefix refinement is unsound.

When multiple cached entries subsume Q, the one with the most
term-sets wins (= most constraints = smallest match set = fastest
refinement scan), with byte-length as a tiebreaker.

*Refinement scoring*: When the scoring thread receives a refine
request, it builds the snap from =m_idx= ∪ =s->cands[delta_from..count]=
instead of the full =s->cands[]=. Typical match sets after 2-3 chars
are <1% of pool size, so refine scans drop by 100-1000× vs full
scans on large pools.

*Critical-section discipline*: =cache_insert= pre-allocates everything
(strdup, malloc, parse_query) *before* taking the cache mutex. The
critical section is just pointer swaps + LRU list manipulation.
Evicted entries are freed after unlock. Lookups bump the SharedIdx
refcount under the mutex, then release it; the consumer holds the
=SharedIdx*= with no further locking.

*fzf semantics gotcha*: within a term-set = OR (terms are
alternatives); across term-sets = AND. So =foo bar= parses as 2 sets
× 1 term (AND-of-two), and =foo | bar= parses as 1 set × 2 terms
(OR-of-two). The cache rejects any term-set with >1 term as an OR
query.

** Memory and string ownership

| Object | Owner | Lifetime |
Expand Down
Binary file modified bin/Darwin/arm64/fzf-native-module.so
Binary file not shown.
Binary file modified bin/FreeBSD/fzf-native-module.so
Binary file not shown.
Binary file modified bin/Linux/fzf-native-module.so
Binary file not shown.
Binary file modified bin/Windows/Release/fzf-native-module.dll
Binary file not shown.
Binary file modified bin/Windows/Release/fzf-native-module.exp
Binary file not shown.
Binary file modified bin/Windows/Release/fzf-native-module.lib
Binary file not shown.
Loading
Loading