Skip to main content

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}