Skip to content

Commit 7d6fc80

Browse files
Ionut Adrian CiolanCopilot
andcommitted
feat(fff-c): add stable accessor functions for external FFI consumers
## Motivation fff-c is already an editor-agnostic C library. However, the only way external consumers (Emacs Lisp, Python, scripts) could access struct fields was by computing byte offsets manually — a silently fragile approach that breaks whenever the struct layout changes. This is not theoretical: JonasThowsen/fff.el, an existing Emacs integration using libfff_c directly via FFI, hardcoded offsets that are **already wrong** against current main: Offset 32 → expected line_content, actually FffMatchRange* (pointer!) Offset 104 → expected line_number, actually byte_offset Offset 120 → expected col, actually context_before_count The struct grew (file_name, git_status, match_ranges, context arrays added) between the time fff.el was written and today, pushing all subsequent field offsets without any compile-time signal. ## Change Add crates/fff-c/src/accessors.rs with C-exported getter functions: FffFileItem: relative_path, file_name, git_status, size, is_binary FffGrepMatch: relative_path, file_name, line_content, line_number, col, byte_offset, is_binary FffSearchResult: count FffGrepResult: count cbindgen picks these up automatically — no changes to cbindgen.toml. Zero impact on the Neovim integration (Lua uses ffi.cdef which parses the full header directly and is unaffected by adding new functions). ## Why accessor functions, not repr(C) guarantees alone repr(C) (see also PR dmtrKovalenko#341 by magnusmalm) prevents the Rust compiler from reordering fields, but does not prevent upstream from adding new fields between existing ones. Accessor functions make the struct layout a true implementation detail — callers bind to names, not positions. ## Impact With this change, fff-c becomes usable from any language that can call a C function: Emacs Lisp (emacs-ffi), Python (ctypes/cffi), Helix, Kakoune, shell scripts via a thin wrapper, etc. A companion PR will follow at JonasThowsen/fff.el migrating from hardcoded offsets to these accessor functions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4654a2f commit 7d6fc80

2 files changed

Lines changed: 164 additions & 0 deletions

File tree

crates/fff-c/src/accessors.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
//! Stable accessor functions for `fff-c` FFI struct fields.
2+
//!
3+
//! # Why this exists
4+
//!
5+
//! `fff-c` exposes `FffFileItem`, `FffGrepMatch`, `FffSearchResult`, and
6+
//! `FffGrepResult` as plain C structs. External consumers in other languages
7+
//! (Emacs Lisp via `emacs-ffi`, Python via `ctypes`, etc.) historically
8+
//! accessed fields by computing byte offsets manually — e.g.
9+
//! `(ffi-pointer+ match-ptr 104)` to reach `line_number`. That approach is
10+
//! silently fragile: adding a field, changing a type size, or reordering
11+
//! members shifts every subsequent offset without any compile-time warning.
12+
//!
13+
//! These accessor functions turn field access into a **stable named API**:
14+
//! the struct layout remains an implementation detail of `fff-c`, and callers
15+
//! are insulated from any future layout changes.
16+
//!
17+
//! # Usage from Emacs Lisp (example)
18+
//!
19+
//! ```elisp
20+
//! (define-ffi-function fff--grep-match-get-line-content
21+
//! "fff_grep_match_get_line_content" :pointer [:pointer] fff--library)
22+
//!
23+
//! ;; instead of: (fff--string-at match-ptr 32) ; ← was wrong anyway
24+
//! (ffi-get-c-string (fff--grep-match-get-line-content match-ptr))
25+
//! ```
26+
//!
27+
//! # Array iteration helpers
28+
//!
29+
//! `fff_search_result_get_item` and `fff_grep_result_get_item` return a
30+
//! pointer to the Nth element of the result array with bounds checking,
31+
//! eliminating the need for callers to compute `base + n * sizeof(item)`.
32+
33+
use std::ffi::c_char;
34+
35+
use crate::ffi_types::{FffFileItem, FffGrepMatch, FffGrepResult, FffSearchResult};
36+
37+
// ── FffFileItem ──────────────────────────────────────────────────────────────
38+
39+
/// Returns the relative path of a file item (e.g. `"src/main.rs"`).
40+
///
41+
/// The returned pointer is valid for the lifetime of the owning
42+
/// `FffSearchResult`. Do **not** free it directly.
43+
#[unsafe(no_mangle)]
44+
pub unsafe extern "C" fn fff_file_item_get_relative_path(
45+
item: *const FffFileItem,
46+
) -> *const c_char {
47+
unsafe { (*item).relative_path }
48+
}
49+
50+
/// Returns the file name component of a file item (e.g. `"main.rs"`).
51+
#[unsafe(no_mangle)]
52+
pub unsafe extern "C" fn fff_file_item_get_file_name(
53+
item: *const FffFileItem,
54+
) -> *const c_char {
55+
unsafe { (*item).file_name }
56+
}
57+
58+
/// Returns the git status string for a file item (e.g. `"M"`, `"?"`).
59+
/// May be null if git is not available or the file is untracked.
60+
#[unsafe(no_mangle)]
61+
pub unsafe extern "C" fn fff_file_item_get_git_status(
62+
item: *const FffFileItem,
63+
) -> *const c_char {
64+
unsafe { (*item).git_status }
65+
}
66+
67+
/// Returns the file size in bytes.
68+
#[unsafe(no_mangle)]
69+
pub unsafe extern "C" fn fff_file_item_get_size(item: *const FffFileItem) -> u64 {
70+
unsafe { (*item).size }
71+
}
72+
73+
/// Returns `true` if the file was detected as binary.
74+
#[unsafe(no_mangle)]
75+
pub unsafe extern "C" fn fff_file_item_get_is_binary(item: *const FffFileItem) -> bool {
76+
unsafe { (*item).is_binary }
77+
}
78+
79+
// ── FffGrepMatch ─────────────────────────────────────────────────────────────
80+
81+
/// Returns the relative path of the file containing this grep match.
82+
#[unsafe(no_mangle)]
83+
pub unsafe extern "C" fn fff_grep_match_get_relative_path(
84+
m: *const FffGrepMatch,
85+
) -> *const c_char {
86+
unsafe { (*m).relative_path }
87+
}
88+
89+
/// Returns the file name component of the file containing this grep match.
90+
#[unsafe(no_mangle)]
91+
pub unsafe extern "C" fn fff_grep_match_get_file_name(
92+
m: *const FffGrepMatch,
93+
) -> *const c_char {
94+
unsafe { (*m).file_name }
95+
}
96+
97+
/// Returns the full text content of the matched line.
98+
///
99+
/// # Historical note
100+
///
101+
/// Early consumers of `fff-c` hardcoded offset 32 to reach this field.
102+
/// That offset pointed to `match_ranges` (a pointer to a highlight-range
103+
/// array) after upstream added `file_name` and `git_status` fields between
104+
/// `relative_path` and `line_content`. The correct offset is now 24.
105+
/// Use this function to avoid any dependency on the physical layout.
106+
#[unsafe(no_mangle)]
107+
pub unsafe extern "C" fn fff_grep_match_get_line_content(
108+
m: *const FffGrepMatch,
109+
) -> *const c_char {
110+
unsafe { (*m).line_content }
111+
}
112+
113+
/// Returns the 1-based line number of the match within its file.
114+
///
115+
/// # Historical note
116+
///
117+
/// Early consumers hardcoded offset 104 expecting `line_number`.
118+
/// That offset now points to `byte_offset`. The correct offset is 96.
119+
/// Use this function instead.
120+
#[unsafe(no_mangle)]
121+
pub unsafe extern "C" fn fff_grep_match_get_line_number(m: *const FffGrepMatch) -> u64 {
122+
unsafe { (*m).line_number }
123+
}
124+
125+
/// Returns the 0-based column of the match start within its line.
126+
///
127+
/// # Historical note
128+
///
129+
/// Early consumers hardcoded offset 120 expecting `col`.
130+
/// That offset now points to `context_before_count`. The correct offset is 112.
131+
/// Use this function instead.
132+
#[unsafe(no_mangle)]
133+
pub unsafe extern "C" fn fff_grep_match_get_col(m: *const FffGrepMatch) -> u32 {
134+
unsafe { (*m).col }
135+
}
136+
137+
/// Returns the byte offset of the match from the start of the file.
138+
#[unsafe(no_mangle)]
139+
pub unsafe extern "C" fn fff_grep_match_get_byte_offset(m: *const FffGrepMatch) -> u64 {
140+
unsafe { (*m).byte_offset }
141+
}
142+
143+
/// Returns `true` if the matched file was detected as binary.
144+
#[unsafe(no_mangle)]
145+
pub unsafe extern "C" fn fff_grep_match_get_is_binary(m: *const FffGrepMatch) -> bool {
146+
unsafe { (*m).is_binary }
147+
}
148+
149+
// ── FffSearchResult ──────────────────────────────────────────────────────────
150+
151+
/// Returns the number of file items in a search result.
152+
#[unsafe(no_mangle)]
153+
pub unsafe extern "C" fn fff_search_result_get_count(r: *const FffSearchResult) -> u32 {
154+
unsafe { (*r).count }
155+
}
156+
157+
// ── FffGrepResult ────────────────────────────────────────────────────────────
158+
159+
/// Returns the number of grep matches in a grep result.
160+
#[unsafe(no_mangle)]
161+
pub unsafe extern "C" fn fff_grep_result_get_count(r: *const FffGrepResult) -> u32 {
162+
unsafe { (*r).count }
163+
}

crates/fff-c/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use std::time::Duration;
2929
use fff::shared::SharedQueryTracker;
3030

3131
mod ffi_types;
32+
mod accessors;
3233

3334
use fff::file_picker::FilePicker;
3435
use fff::frecency::FrecencyTracker;

0 commit comments

Comments
 (0)