Skip to main content

lattice_host/
grep_highlight.rs

1//! PH.3: concrete grep-preview highlighter.
2//!
3//! `lattice-picker` defines the [`GrepPreviewHighlighter`] trait but has
4//! no `lattice-syntax` dependency — that absence is the structural
5//! off-thread guarantee (a picker source physically cannot parse on the
6//! render thread). The host owns `lattice-syntax`, so the concrete impl
7//! lives here and is injected into `GrepSource` at boot.
8//!
9//! Grep hits come from arbitrary files (not the active buffer's parsed
10//! tree), so each preview line is highlighted by selecting a grammar
11//! from the file extension and parsing the single line. The expensive
12//! part — compiling a grammar's highlight query — is cached per
13//! language across calls (and across live-grep keystrokes), so steady
14//! state is just a short single-line parse per hit. All of this runs on
15//! the grep blocking task (`GrepSource::spawn_grep`), never the render
16//! thread.
17//!
18//! See `docs/dev/architecture/picker-preview-highlight.md` §7.
19
20use std::collections::HashMap;
21use std::path::Path;
22use std::sync::{Arc, Mutex};
23
24use lattice_completion::DisplaySpan;
25use lattice_picker::picker_sources::GrepPreviewHighlighter;
26use lattice_syntax::{Lang, Syntax};
27
28/// Per-language grammar cache backing the grep preview highlighter.
29/// `None` in the map means "this language has no registered grammar" —
30/// cached so repeated hits in an unsupported file don't re-probe the
31/// registry. Wrapped in a `Mutex` because the trait is `Sync` and grep
32/// runs are serial within a task but may overlap across runs.
33pub struct SyntaxGrepHighlighter {
34    cache: Mutex<HashMap<Lang, Option<Syntax>>>,
35}
36
37impl SyntaxGrepHighlighter {
38    /// AH.1: takes no registry. It used to take the editor's shared
39    /// `Arc<LangRegistry>` "so grep previews highlight with exactly the
40    /// grammars the buffers use" — which is what it stopped doing the moment
41    /// plugin languages existed.
42    ///
43    /// `LangRegistry::standard()` *is* `registry::live()`: it returns a
44    /// SNAPSHOT of the process-global ArcSwap. A plugin's grammar arrives
45    /// later via `install_plugin_config`, which RCUs a new registry in and
46    /// leaves every held `Arc` on the pre-plugin value. This highlighter was
47    /// built at boot, so its registry was bundled-only forever — every
48    /// preview of a `.org` hit (the org-roam node picker's whole surface)
49    /// painted plain.
50    pub fn new() -> Arc<Self> {
51        Arc::new(Self::default())
52    }
53}
54
55impl Default for SyntaxGrepHighlighter {
56    fn default() -> Self {
57        Self {
58            cache: Mutex::new(HashMap::new()),
59        }
60    }
61}
62
63impl GrepPreviewHighlighter for SyntaxGrepHighlighter {
64    fn highlight_line(&self, path: &Path, line: &str) -> Vec<DisplaySpan> {
65        // Grammar by extension. `Lang::Plain` (and any extension we
66        // don't recognise) → no highlighting, plain preview.
67        let lang = Lang::detect_from_path(Some(path));
68        if lang == Lang::Plain || line.is_empty() {
69            return Vec::new();
70        }
71        // AH.1: the LIVE registry, re-read per call. Wait-free (one ArcSwap
72        // load), and the only way a grammar registered after boot is ever
73        // seen.
74        let Ok(live) = lattice_syntax::registry::live() else {
75            return Vec::new();
76        };
77        // Recover a poisoned lock rather than propagate a panic onto the
78        // grep task — a highlight failure must degrade to plain preview.
79        let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
80        // The cache stores NEGATIVES ("no grammar for this language") so
81        // repeated hits in an unsupported file do not re-probe. That is safe
82        // across a plugin registration, which is worth stating because it
83        // looks like it should not be: the key is `Lang`, and a
84        // `Lang::Plugin(name)` only *exists* while that name is registered.
85        // Before registration the extension resolves to `Lang::Plain` and
86        // returns above without touching the cache; after withdrawal it does
87        // so again. So a registration never has to invalidate an entry — it
88        // introduces a key that could not have been cached under.
89        let entry = cache.entry(lang).or_insert_with(|| {
90            Syntax::for_language_with_registry(lang, live)
91                .ok()
92                .flatten()
93        });
94        let Some(syntax) = entry.as_mut() else {
95            return Vec::new(); // no grammar for this language
96        };
97        // Re-parse just this line under the cached grammar (the compiled
98        // highlight query is what `Syntax` already holds). `line` IS the
99        // candidate `display`, so spans come back display-relative.
100        syntax.parse_at(line, 0);
101        let Ok(per_line) = syntax.highlight_lines(0, 1) else {
102            return Vec::new();
103        };
104        let Some(spans) = per_line.into_iter().next() else {
105            return Vec::new();
106        };
107        spans
108            .into_iter()
109            .filter(|s| s.start < line.len())
110            .map(|s| DisplaySpan {
111                range: s.start..s.end.min(line.len()),
112                style: s.style,
113            })
114            .collect()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    #![allow(clippy::unwrap_used)]
121    use super::*;
122    use std::path::PathBuf;
123
124    fn highlighter() -> Arc<SyntaxGrepHighlighter> {
125        SyntaxGrepHighlighter::new()
126    }
127
128    /// PH.3: a Rust grep hit's preview is highlighted display-relative,
129    /// keyword colored, all spans within the line length.
130    #[test]
131    fn highlights_rust_preview_display_relative() {
132        let h = highlighter();
133        let line = "let x = 1;";
134        let spans = h.highlight_line(&PathBuf::from("src/main.rs"), line);
135        assert!(!spans.is_empty(), "a rust line should carry syntax spans");
136        assert!(
137            spans.iter().all(|s| s.range.end <= line.len()),
138            "spans stay within the display run"
139        );
140        assert!(
141            spans
142                .iter()
143                .any(|s| s.style == lattice_syntax::Style::Keyword),
144            "`let` resolves to the Keyword style"
145        );
146    }
147
148    /// PH.3: an unrecognised extension (or no extension) → plain
149    /// preview (no grammar), never a panic.
150    #[test]
151    fn unknown_extension_is_plain() {
152        let h = highlighter();
153        assert!(
154            h.highlight_line(&PathBuf::from("notes.unknownext"), "let x = 1;")
155                .is_empty()
156        );
157        assert!(
158            h.highlight_line(&PathBuf::from("README"), "plain text line")
159                .is_empty()
160        );
161    }
162
163    /// PH.3: empty preview → no spans.
164    #[test]
165    fn empty_line_is_plain() {
166        let h = highlighter();
167        assert!(
168            h.highlight_line(&PathBuf::from("src/main.rs"), "")
169                .is_empty()
170        );
171    }
172
173    /// PH.3: the per-language grammar cache is reused across calls
174    /// (second call for the same language hits the cache, not a fresh
175    /// `for_language_with_registry`). Observable as identical output.
176    #[test]
177    fn caches_grammar_across_calls() {
178        let h = highlighter();
179        let a = h.highlight_line(&PathBuf::from("a.rs"), "fn main() {}");
180        let b = h.highlight_line(&PathBuf::from("b.rs"), "fn main() {}");
181        assert_eq!(a, b, "same grammar + same line → identical spans");
182        assert!(!a.is_empty());
183    }
184
185    /// **AH.1: a grammar registered AFTER the highlighter was built must
186    /// reach previews.**
187    ///
188    /// This is the org-roam node picker painting every `.org` preview plain.
189    /// The highlighter held an `Arc<LangRegistry>` captured at boot, and
190    /// `LangRegistry::standard()` returns a SNAPSHOT — a plugin's
191    /// `install_plugin_config` RCUs a NEW registry into the process-global
192    /// ArcSwap, so every previously-held `Arc` keeps pointing at the
193    /// bundled-only value from before the plugin loaded.
194    ///
195    /// The first assertion is the mechanism itself, stated so it cannot rot
196    /// into a coincidence: registration REPLACES the registry rather than
197    /// mutating it, which is exactly why holding one is wrong. The second is
198    /// the behaviour that depends on it.
199    #[test]
200    fn a_grammar_registered_after_boot_reaches_previews() {
201        const PROV: u64 = 0xA11_7E57;
202        let h = highlighter();
203        let path = PathBuf::from("notes.testlang-ah1");
204
205        // Held at "boot", exactly as the pre-AH.1 highlighter did.
206        let held_at_boot = lattice_syntax::LangRegistry::standard().unwrap();
207        assert!(
208            h.highlight_line(&path, "fn main() {}").is_empty(),
209            "sanity: the extension is unclaimed before registration"
210        );
211
212        let spec = lattice_syntax::registry::GrammarSpec {
213            grammar: tree_sitter_rust::LANGUAGE.into(),
214            highlights: Some(tree_sitter_rust::HIGHLIGHTS_QUERY.to_string()),
215            folds: None,
216            injections: None,
217            indents: None,
218            textobjects: None,
219            conceal_rules: Vec::new(),
220        };
221        lattice_syntax::plugin_lang::register_with_grammar(
222            "testlang-ah1",
223            &["testlang-ah1"],
224            &spec,
225            PROV,
226        )
227        .expect("the test language registers");
228
229        // THE MECHANISM: registration swapped in a different registry, so the
230        // `Arc` captured above can never see it. Any code holding one is
231        // reading the pre-plugin world forever.
232        let now_live = lattice_syntax::LangRegistry::standard().unwrap();
233        assert!(
234            !Arc::ptr_eq(&held_at_boot, &now_live),
235            "registration must REPLACE the live registry — if it mutated in \
236             place, holding an Arc would have been safe and AH.1 would be \
237             solving a non-problem"
238        );
239        assert!(
240            lattice_syntax::Syntax::for_language_with_registry(
241                Lang::detect_from_path(Some(&path)),
242                held_at_boot,
243            )
244            .is_ok_and(|o| o.is_none()),
245            "…and the held snapshot still does not know the language"
246        );
247
248        // THE BEHAVIOUR: the preview highlights anyway, because the
249        // highlighter reads live.
250        assert!(
251            !h.highlight_line(&path, "fn main() {}").is_empty(),
252            "a grammar registered after boot must reach previews"
253        );
254
255        // Withdrawal is visible too. Note WHY, since it is not the cache
256        // being invalidated: `unregister_plugin` withdraws the extension
257        // mapping as well, so the path resolves to `Lang::Plain` again and
258        // returns before the cache is consulted. The cached entry is keyed by
259        // a `Lang::Plugin` that no longer exists and is unreachable rather
260        // than stale — which is what makes a cache-invalidation pass
261        // unnecessary here.
262        lattice_syntax::plugin_lang::unregister_plugin(PROV);
263        assert!(
264            h.highlight_line(&path, "fn main() {}").is_empty(),
265            "a withdrawn grammar must stop highlighting"
266        );
267    }
268}