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}