lattice_plugin_host/scanned_excerpt_source.rs
1//! OM.A1 — the `WasmScannedExcerptSource` adapter.
2//!
3//! Wraps an agenda plugin's [`ScanClient`] bridge and exposes a **native**-
4//! typed producer the host's scan calls, exactly like `WasmMediaSource`. The
5//! provider that drives it lives in `lattice-multibuffer` and knows nothing
6//! about WASM; this is the only place the two meet.
7
8use std::path::PathBuf;
9use std::sync::{Arc, Mutex};
10
11use lattice_mode::scanned_excerpt_source::{
12 ClockSpan, ScanBeginFuture, ScanFuture, ScanResult, ScanRootsFuture, ScannedExcerpt,
13 ScannedExcerptSource,
14};
15
16use crate::PluginId;
17use crate::scan_cache::ScanCache;
18use crate::scan_task::ClockSpan as WitClockSpan;
19use crate::scan_task::{Annotation, Entry, ScanClient};
20
21/// An async agenda-row producer over a plugin's [`ScanClient`].
22#[derive(Clone, Debug)]
23pub struct WasmScannedExcerptSource {
24 client: ScanClient,
25 /// Resolved ONCE at load, so the walk's per-file test is a string compare
26 /// rather than a guest call. A `scan`-per-file boundary crossing is
27 /// already the producer's dominant cost; adding an `extensions()` call
28 /// per file would double it to answer a question that cannot change.
29 extensions: Vec<String>,
30 /// Resolved once at load, like `extensions` and for the same reason: the
31 /// provider reads it on every open, and the answer cannot change.
32 view_mode: Option<String>,
33 /// OT.3b: scan results, persisted across restarts.
34 ///
35 /// It lives HERE, above `client`, because that is the layer where a hit can
36 /// skip the guest call as well as the parse — a cache below the boundary
37 /// could only ever have saved the parse. `Mutex` because `ScannedExcerptSource`
38 /// hands out `&self` and the source is cloned into the scan task; `Arc` so
39 /// every clone shares one cache rather than one each, which would make the
40 /// hit rate depend on how many times the source was cloned.
41 ///
42 /// `None` when the host could not resolve a data directory. A cache that
43 /// cannot be stored is simply not used.
44 cache: Option<Arc<Mutex<ScanCache>>>,
45}
46
47impl ScannedExcerptSource for WasmScannedExcerptSource {
48 fn source_id(&self) -> u64 {
49 self.plugin_id().0 as u64
50 }
51
52 fn extensions(&self) -> &[String] {
53 &self.extensions
54 }
55
56 fn view_mode(&self) -> Option<&str> {
57 self.view_mode.as_deref()
58 }
59
60 /// AF.1. NOT cached beside `extensions` / `view_mode`, and that asymmetry
61 /// is the point: those are facts about the source, this is a fact about the
62 /// user's config. Caching it would make the source's own setting appear not
63 /// to work until the editor restarted.
64 ///
65 /// A guest that traps or is quarantined answers empty rather than failing
66 /// the scan — the same degradation `begin` gets, one step earlier.
67 fn roots(&self) -> ScanRootsFuture<'_> {
68 Box::pin(async move {
69 match self.client.roots().await {
70 Ok(roots) => Ok(roots),
71 Err(e) => {
72 tracing::debug!(error = %e, "scan: a source could not name its roots");
73 Ok(Vec::new())
74 }
75 }
76 })
77 }
78
79 /// OA.22. Degrades to empty on a trap or a quarantined guest, exactly as
80 /// `roots` does — a header that could not be built is a missing phrase, not
81 /// a failed scan.
82 fn describe(&self, args: &[String]) -> lattice_mode::ScanDescribeFuture<'_> {
83 let args = args.to_vec();
84 Box::pin(async move {
85 match self.client.describe(args).await {
86 Ok(label) => label,
87 Err(e) => {
88 tracing::debug!(error = %e, "scan: a source could not describe its view");
89 String::new()
90 }
91 }
92 })
93 }
94
95 fn begin(&self, args: &[String]) -> ScanBeginFuture<'_> {
96 // Owned before the async move: `args` is borrowed from the caller and
97 // the returned future outlives the call.
98 let args = args.to_vec();
99 Box::pin(async move {
100 // OT.3b: `begin` now answers with the guest's generation key, and
101 // handing it to the cache is what discards rows computed under an
102 // anchor that no longer holds (org: yesterday's day, an old keyword
103 // set). The host never learns what the key means.
104 //
105 // OA.11a: the view's scan args go in, uninterpreted. A guest that
106 // scans differently for different args folds them into the key it
107 // returns — which is what stops a dispatcher's second command from
108 // reading rows the first one cached.
109 let generation = self
110 .client
111 .begin(args)
112 .await
113 .map_err(|e| format!("scan plugin: {e}"))?;
114 if let Some(cache) = &self.cache
115 && let Ok(mut cache) = cache.lock()
116 {
117 cache.begin(generation);
118 }
119 Ok(())
120 })
121 }
122
123 fn scan(&self, path: PathBuf, text: String) -> ScanFuture<'_> {
124 Box::pin(async move {
125 let display = path.display().to_string();
126 // OT.3b: an unchanged file skips the parse AND the guest call. The
127 // read already happened upstream — you cannot know a file is
128 // unchanged without looking at it — and a warm read is ~10-50 us
129 // against the ~2 ms parse this avoids.
130 if let Some(cache) = &self.cache
131 && let Ok(mut cache) = cache.lock()
132 && let Some((rows, clock)) = cache.get(&display, &text)
133 {
134 return Ok(ScanResult {
135 entries: rows
136 .into_iter()
137 .filter_map(|e| validate(&display, e))
138 .collect(),
139 clock: clock.into_iter().map(native_clock_span).collect(),
140 });
141 }
142 let raw = match self.client.scan(display.clone(), text.clone()).await {
143 // The guest's own `err` and a host-surface failure land in the
144 // same place because the caller does the same thing with both:
145 // skip this file, keep scanning.
146 Ok(inner) => inner?,
147 Err(host_err) => return Err(format!("scan plugin: {host_err}")),
148 };
149 if let Some(cache) = &self.cache
150 && let Ok(mut cache) = cache.lock()
151 {
152 cache.put(&display, &text, &raw.entries, &raw.clock);
153 }
154 Ok(ScanResult {
155 entries: raw
156 .entries
157 .into_iter()
158 .filter_map(|e| validate(&display, e))
159 .collect(),
160 clock: raw.clock.into_iter().map(native_clock_span).collect(),
161 })
162 })
163 }
164}
165
166impl WasmScannedExcerptSource {
167 pub fn new(client: ScanClient, extensions: Vec<String>, view_mode: Option<String>) -> Self {
168 Self {
169 client,
170 extensions,
171 view_mode,
172 cache: None,
173 }
174 }
175
176 /// OT.3b: give this source a persistent result cache rooted at `dir`
177 /// (the plugin's own data directory, so two plugins cannot collide and
178 /// uninstalling one removes its cache).
179 ///
180 /// Opt-in rather than built in `new`: a caller with no data directory —
181 /// tests, benches — gets an uncached source that behaves identically, just
182 /// slower, which is also what makes the cache easy to A/B.
183 pub fn with_cache(mut self, dir: &std::path::Path) -> Self {
184 let id = self.plugin_id().0 as u64;
185 self.cache = Some(Arc::new(Mutex::new(ScanCache::open(dir, id))));
186 self
187 }
188
189 pub fn plugin_id(&self) -> PluginId {
190 self.client.id()
191 }
192}
193
194/// Normalise a declared extension: lowercase, leading dots stripped, blanks
195/// dropped. A guest writing `".ORG"` and a guest writing `"org"` mean the same
196/// filetype, and making the host guess which spelling it got is how a
197/// producer ends up silently scanning nothing.
198pub fn normalise_extensions(raw: Vec<String>) -> Vec<String> {
199 let mut out: Vec<String> = Vec::new();
200 for e in raw {
201 let cleaned = e.trim().trim_start_matches('.').to_ascii_lowercase();
202 if cleaned.is_empty() {
203 continue;
204 }
205 if !out.contains(&cleaned) {
206 out.push(cleaned);
207 }
208 }
209 out
210}
211
212/// Convert one WIT entry into a native one, or drop it.
213///
214/// Guest output is untrusted, exactly as `error_parser_host::validate` treats
215/// a parsed diagnostic. The rejections here are the ones a buggy guest
216/// actually produces: an inverted span (`end_line < line`, an off-by-one in
217/// the guest's own subtree walk) and a line near `u32::MAX` (an underflow in a
218/// 1-based → 0-based conversion). Both would otherwise become an excerpt that
219/// renders nothing and jumps nowhere.
220///
221/// `debug!`, never `info!` — this fires per row of a project-wide scan.
222/// OA.14b: the WIT clock span, native side.
223///
224/// Unvalidated where [`validate`] is not, and deliberately: a row carries line
225/// numbers the host turns into an excerpt, so a bad one renders nothing and
226/// jumps nowhere. A clock span is only ever summed into a report, so the worst
227/// a nonsensical one produces is a wrong total in a row that names itself —
228/// visible, and not worth dropping data over.
229fn native_clock_span(c: WitClockSpan) -> ClockSpan {
230 ClockSpan {
231 line: c.line,
232 outline: c.outline,
233 day: c.day,
234 minutes: c.minutes,
235 }
236}
237
238fn validate(path: &str, e: Entry) -> Option<ScannedExcerpt> {
239 if e.line == u32::MAX || e.end_line == u32::MAX {
240 tracing::debug!(
241 path,
242 line = e.line,
243 end_line = e.end_line,
244 "scan source returned an out-of-range line; skipping the row"
245 );
246 return None;
247 }
248 if e.end_line < e.line {
249 tracing::debug!(
250 path,
251 line = e.line,
252 end_line = e.end_line,
253 "scan source returned an inverted span; skipping the row"
254 );
255 return None;
256 }
257 Some(ScannedExcerpt {
258 line: e.line,
259 end_line: e.end_line,
260 group: e.group,
261 label: e.label,
262 sort_key: e.sort_key,
263 // OA.5: spans are guest output too, so they are validated rather than
264 // trusted. Dropped PER SPAN, not per row — `display-span`'s own
265 // contract is that one bad run must not cost a row its other runs,
266 // and a row that vanished because its colour was wrong would be a
267 // much worse failure than a row that renders plain.
268 spans: e
269 .spans
270 .into_iter()
271 .filter(|s| {
272 let ok = s.end > s.start && !s.slot.is_empty();
273 if !ok {
274 tracing::debug!(
275 path,
276 line = e.line,
277 start = s.start,
278 end = s.end,
279 slot = %s.slot,
280 "scan source returned an empty or inverted span; skipping it"
281 );
282 }
283 ok
284 })
285 .map(|s| lattice_mode::scanned_excerpt_source::RowSpan {
286 start: s.start,
287 end: s.end,
288 slot: s.slot,
289 })
290 .collect(),
291 // MH.A6: nothing to validate — a bool has no range to be out of, and
292 // it names no slot that could fail to resolve. The worst a guest can
293 // do with it is emphasise every group, which is a taste failure rather
294 // than a correctness one and not the host's to police.
295 emphasis: e.emphasis,
296 // HB.5: same rule as `spans`, one level in. An annotation whose spans
297 // are all bad still renders its text, and a row never loses its
298 // annotation because a decoration was malformed — the row is the
299 // information, the colour is the polish.
300 annotation: e
301 .annotation
302 .and_then(|a| validate_annotation(path, e.line, a)),
303 })
304}
305
306/// HB.5: an annotation is dropped only when it has no text to render.
307///
308/// Its spans index into `text`, so the bound they are checked against is
309/// `text.len()` and not the source line's — the annotation is not part of that
310/// line. An out-of-range span would otherwise paint a run of a string it does
311/// not belong to, which is the one way a bad decoration can produce something
312/// worse than no decoration.
313fn validate_annotation(
314 path: &str,
315 line: u32,
316 a: Annotation,
317) -> Option<lattice_mode::scanned_excerpt_source::RowAnnotation> {
318 if a.text.is_empty() {
319 tracing::debug!(
320 path,
321 line,
322 "scan source returned an empty annotation; skipping it"
323 );
324 return None;
325 }
326 let len = a.text.len() as u32;
327 let spans = a
328 .spans
329 .into_iter()
330 .filter(|s| {
331 let ok = s.end > s.start && s.end <= len && !s.slot.is_empty();
332 if !ok {
333 tracing::debug!(
334 path,
335 line,
336 start = s.start,
337 end = s.end,
338 text_len = len,
339 slot = %s.slot,
340 "scan source returned a bad annotation span; skipping it"
341 );
342 }
343 ok
344 })
345 .map(|s| lattice_mode::scanned_excerpt_source::RowSpan {
346 start: s.start,
347 end: s.end,
348 slot: s.slot,
349 })
350 .collect();
351 Some(lattice_mode::scanned_excerpt_source::RowAnnotation {
352 text: a.text,
353 spans,
354 })
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::scan_task::DisplaySpan;
361
362 fn entry(line: u32, end_line: u32) -> Entry {
363 Entry {
364 line,
365 end_line,
366 group: "Today".into(),
367 label: "TODO write tests".into(),
368 sort_key: 42,
369 spans: Vec::new(),
370 annotation: None,
371 emphasis: false,
372 }
373 }
374
375 fn annotated(text: &str, spans: Vec<DisplaySpan>) -> Entry {
376 let mut e = entry(3, 3);
377 e.annotation = Some(Annotation {
378 text: text.to_string(),
379 spans,
380 });
381 e
382 }
383
384 fn span(start: u32, end: u32) -> DisplaySpan {
385 DisplaySpan {
386 start,
387 end,
388 slot: "habit".into(),
389 }
390 }
391
392 /// OA.5: guest output is untrusted, and a bad span must cost the row its
393 /// COLOUR, never the row. Dropped per span rather than per row —
394 /// `display-span`'s own contract is that one bad run must not take the
395 /// others with it, and a row that vanished because its colour was wrong
396 /// would be a far worse failure than one that renders plain.
397 #[test]
398 fn a_bad_span_is_dropped_without_losing_the_row() {
399 let mut e = entry(3, 3);
400 e.spans = vec![
401 // Inverted.
402 DisplaySpan {
403 start: 6,
404 end: 2,
405 slot: "keyword".into(),
406 },
407 // Empty.
408 DisplaySpan {
409 start: 4,
410 end: 4,
411 slot: "keyword".into(),
412 },
413 // No slot to resolve.
414 DisplaySpan {
415 start: 1,
416 end: 3,
417 slot: String::new(),
418 },
419 // The good one.
420 DisplaySpan {
421 start: 2,
422 end: 6,
423 slot: "keyword".into(),
424 },
425 ];
426 let row = validate("/p/a.org", e).expect("the row survives its bad spans");
427 assert_eq!(
428 row.spans
429 .iter()
430 .map(|s| (s.start, s.end))
431 .collect::<Vec<_>>(),
432 vec![(2, 6)]
433 );
434 assert_eq!(row.spans[0].slot, "keyword");
435 }
436
437 #[test]
438 fn a_well_formed_entry_converts() {
439 let got = validate("/p/n.org", entry(9, 10)).expect("accepted");
440 assert_eq!((got.line, got.end_line), (9, 10));
441 assert_eq!(got.group, "Today");
442 assert_eq!(got.sort_key, 42);
443 }
444
445 /// A single-line row is the common case and must not look inverted.
446 #[test]
447 fn a_single_line_span_is_accepted() {
448 assert!(validate("/p/n.org", entry(4, 4)).is_some());
449 }
450
451 #[test]
452 fn an_inverted_span_is_dropped() {
453 assert!(validate("/p/n.org", entry(10, 9)).is_none());
454 }
455
456 /// What a guest's own 1-based → 0-based conversion produces when it
457 /// underflows on line 0.
458 #[test]
459 fn an_out_of_range_line_is_dropped() {
460 assert!(validate("/p/n.org", entry(u32::MAX, u32::MAX)).is_none());
461 assert!(validate("/p/n.org", entry(0, u32::MAX)).is_none());
462 }
463
464 // ── HB.5: the annotation ────────────────────────────────────────────
465
466 #[test]
467 fn an_annotation_crosses_with_its_spans() {
468 let row = validate("/p/n.org", annotated("···✓··", vec![span(0, 3)]))
469 .expect("accepted")
470 .annotation
471 .expect("the annotation crossed");
472 assert_eq!(row.text, "···✓··");
473 assert_eq!(row.spans.len(), 1);
474 assert_eq!((row.spans[0].start, row.spans[0].end), (0, 3));
475 assert_eq!(row.spans[0].slot, "habit");
476 }
477
478 /// `none` is the ordinary case and must stay distinguishable from an empty
479 /// one — an agenda of plain TODOs grows no second rows.
480 #[test]
481 fn no_annotation_stays_none() {
482 assert!(
483 validate("/p/n.org", entry(3, 3))
484 .expect("accepted")
485 .annotation
486 .is_none()
487 );
488 }
489
490 /// The rule `entry.spans` set, one level in: a bad span costs itself, and
491 /// the annotation still renders its text. A graph that lost its row because
492 /// one cell's colour was wrong would be the worse failure.
493 #[test]
494 fn a_bad_annotation_span_costs_itself_not_the_annotation() {
495 let a = annotated(
496 "······",
497 vec![
498 span(5, 5), // empty
499 span(4, 2), // inverted
500 DisplaySpan {
501 start: 0,
502 end: 3,
503 slot: String::new(),
504 }, // no slot
505 span(0, 3), // the survivor
506 ],
507 );
508 let got = validate("/p/n.org", a)
509 .expect("accepted")
510 .annotation
511 .expect("the annotation survives its bad spans");
512 assert_eq!(got.text, "······");
513 assert_eq!(
514 got.spans
515 .iter()
516 .map(|s| (s.start, s.end))
517 .collect::<Vec<_>>(),
518 vec![(0, 3)]
519 );
520 }
521
522 /// The bound is the ANNOTATION's text, not the row's source line — the
523 /// annotation is not part of that line. A span past the end would paint a
524 /// run of a string it does not belong to, which is the one way a bad
525 /// decoration is worse than none.
526 #[test]
527 fn a_span_past_the_annotations_own_end_is_dropped() {
528 let got = validate("/p/n.org", annotated("abc", vec![span(0, 99), span(0, 3)]))
529 .expect("accepted")
530 .annotation
531 .expect("present");
532 assert_eq!(
533 got.spans
534 .iter()
535 .map(|s| (s.start, s.end))
536 .collect::<Vec<_>>(),
537 vec![(0, 3)],
538 "only the in-range span survives"
539 );
540 }
541
542 /// An annotation with no text has nothing to render, so it is dropped
543 /// rather than becoming a blank row the user cannot explain.
544 #[test]
545 fn an_empty_annotation_is_dropped() {
546 assert!(
547 validate("/p/n.org", annotated("", vec![span(0, 1)]))
548 .expect("the ROW still survives")
549 .annotation
550 .is_none()
551 );
552 }
553
554 /// And the row itself is never lost to a bad annotation.
555 #[test]
556 fn a_bad_annotation_never_costs_the_row() {
557 let got = validate("/p/n.org", annotated("", Vec::new())).expect("the row survives");
558 assert_eq!((got.line, got.end_line), (3, 3));
559 }
560
561 #[test]
562 fn extensions_are_lowercased_and_dot_stripped() {
563 let got = normalise_extensions(vec![".ORG".into(), "Md".into()]);
564 assert_eq!(got, vec!["org".to_string(), "md".to_string()]);
565 }
566
567 /// A duplicate would make the walk offer one file to one source twice.
568 #[test]
569 fn duplicate_and_blank_extensions_are_dropped() {
570 let got = normalise_extensions(vec![
571 "org".into(),
572 ".org".into(),
573 " ".into(),
574 ".".into(),
575 "".into(),
576 ]);
577 assert_eq!(got, vec!["org".to_string()]);
578 }
579}