Skip to main content

lattice_mode/
scanned_excerpt_source.rs

1//! OM.A1 — the registry of agenda-row producers.
2//!
3//! The agenda twin of [`media_source`](crate::media_source), and the same
4//! contract: an async, off-keystroke-path producer the host drives on a
5//! trigger. Where a media source answers "what images does this buffer show",
6//! an agenda source answers "what dated rows does this FILE contribute" —
7//! once per file of a project walk.
8//!
9//! Nothing here names org. A source declares the extensions it wants offered
10//! ([`ScannedExcerptSource::extensions`]) and the host offers it only those
11//! files, which is what keeps `.org` out of the host walk.
12
13use std::future::Future;
14use std::path::PathBuf;
15use std::pin::Pin;
16use std::sync::Arc;
17
18use arc_swap::ArcSwap;
19
20/// One agenda row a producer found in a file.
21///
22/// The native mirror of the WIT `entry` (`wit/scanned-excerpt-source.wit`). It is a
23/// *span in a file*, not a rendered string, because the agenda is literally a
24/// multibuffer of excerpts — which is what buys jump-to-source and
25/// edit-propagates-to-source for free (`org-mode.md` §6.1).
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ScannedExcerpt {
28    /// 0-based first line of the row's excerpt.
29    pub line: u32,
30    /// 0-based last line of the excerpt, inclusive. Equal to `line` for the
31    /// one-row-per-headline case.
32    pub end_line: u32,
33    /// Grouping **key**. Rows that sort adjacently and share a key render
34    /// under one header. A key rather than a label because a producer
35    /// cannot know which of its rows lands first once other files' rows
36    /// are interleaved — the host compares keys after the sort.
37    pub group: String,
38    /// The header title, used when this row turns out to start a group.
39    pub label: String,
40    /// The host stable-sorts every file's rows together on this, ascending.
41    /// The producer owns what it means.
42    pub sort_key: i64,
43    /// How this row is coloured, as byte spans into the row's own first
44    /// line — NOT into the composed view, which the producer cannot see until
45    /// every other file's rows have been interleaved by the sort (OA.5).
46    ///
47    /// Empty is the ordinary case, and means "say nothing about colour": the
48    /// source file's own grammar highlighting is what shows, unchanged. A
49    /// producer fills this when the row has semantics its grammar does not
50    /// carry — an agenda's TODO keyword, priority and tags are org's, not the
51    /// org grammar's, which is why an agenda looked like org text out of
52    /// order before this existed.
53    pub spans: Vec<RowSpan>,
54    /// A row to hang BELOW this one, or `None` (HB.5).
55    ///
56    /// The WIT `annotation`, native side. A row's text is a verbatim excerpt of
57    /// a source line, so a producer with something of its own to show — a
58    /// habit's consistency graph — has nowhere to put it; this becomes a
59    /// virtual row anchored below instead.
60    ///
61    /// `None` is the ordinary case. A scan of plain TODOs grows no second rows.
62    pub annotation: Option<RowAnnotation>,
63    /// MH.A6: render this row's header EMPHASISED, when it turns out to start
64    /// a group.
65    ///
66    /// Read only on the row that starts the group — the same rule
67    /// [`label`](Self::label) already lives by, and for the same reason: a
68    /// producer cannot know which of its rows lands first after the sort, so
69    /// it sets the field on every row of the group and the host reads whichever
70    /// one wins. Setting it inconsistently across a group is a producer bug
71    /// that shows as "sometimes emphasised".
72    ///
73    /// `false` is the ordinary case and leaves the header byte-identical to
74    /// before this field existed.
75    pub emphasis: bool,
76}
77
78/// One line hung below a row, and how it is coloured (HB.5).
79///
80/// The WIT `annotation`, native side. Its [`spans`](Self::spans) index into
81/// [`text`](Self::text) — not into the row's source line, which this is not
82/// part of — and resolve through the same path [`RowSpan`] does.
83#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub struct RowAnnotation {
85    /// The hung line's text (one line; it is not part of any source file).
86    pub text: String,
87    /// Styled runs, as byte offsets into [`text`](Self::text).
88    pub spans: Vec<RowSpan>,
89}
90
91/// One styled run within a row, naming a style rather than carrying one.
92///
93/// The WIT `display-span`, native side. `slot` resolves host-side through the
94/// same path a `highlights.scm` capture takes, so a plugin's own registered
95/// theme element (`org.todo.WAITING`) reaches the row with the active
96/// colourscheme applied, and an unresolvable name renders unstyled rather
97/// than failing the row.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct RowSpan {
100    /// Byte offset from the start of the row's line.
101    pub start: u32,
102    /// Byte offset one past the run's last byte (exclusive).
103    pub end: u32,
104    /// Capture or theme-element name.
105    pub slot: String,
106}
107
108/// Time clocked on one headline on one day (OA.14b).
109///
110/// The WIT `clock-span`, native side. Reported for every clocked headline a
111/// producer saw — NOT only for the ones that became rows. A clock report totals
112/// what you actually logged, and agenda rows are a filtered subset, so a
113/// headline clocked yesterday with no TODO and no date must still count.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ClockSpan {
116    /// 0-based line of the HEADLINE the time was logged under.
117    pub line: u32,
118    /// Outline path, outermost ancestor first, the headline itself last. Its
119    /// length is the outline level.
120    ///
121    /// A path rather than a name plus a level, because the report is a
122    /// hierarchy whose totals roll up it: an ancestor that logged no time of
123    /// its own emits no span, so the chain is the only way to name it.
124    pub outline: Vec<String>,
125    /// Days since the Unix epoch the time is filed under.
126    pub day: i64,
127    /// Minutes clocked, already summed per (headline, day) by the producer.
128    pub minutes: u32,
129}
130
131/// What one file's scan produced.
132///
133/// A record rather than a bare row list because the clock report is not a view
134/// of the rows — see [`ClockSpan`]. It rides the same call so the walk still
135/// makes ONE producer call per file: the scan is a producer's critical path,
136/// and a second crossing to carry data most files have none of would double it.
137#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct ScanResult {
139    /// The agenda rows this file contributes, in any order (the host sorts).
140    pub entries: Vec<ScannedExcerpt>,
141    /// Every clocked (headline, day) the producer saw in the file, whether
142    /// or not it became a row.
143    pub clock: Vec<ClockSpan>,
144}
145
146impl ScanResult {
147    /// The common case: rows and nothing clocked.
148    pub fn rows(entries: Vec<ScannedExcerpt>) -> Self {
149        Self {
150            entries,
151            clock: Vec::new(),
152        }
153    }
154}
155
156/// The boxed future an [`ScannedExcerptSource::scan`] returns.
157///
158/// `Err(reason)` skips THIS FILE and the scan continues — one malformed file
159/// must not fail the agenda. That is `error-parser`'s rule, because it is the
160/// same failure class.
161pub type ScanFuture<'a> = Pin<Box<dyn Future<Output = Result<ScanResult, String>> + Send + 'a>>;
162
163/// The boxed future an [`ScannedExcerptSource::begin`] returns.
164///
165/// Separate from [`ScanFuture`] rather than reusing it with an ignored
166/// `Vec`: `begin` produces nothing, and a signature that says otherwise
167/// invites a producer to return rows from it that the scan would drop.
168pub type ScanBeginFuture<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
169
170/// The future [`ScannedExcerptSource::describe`] returns (OA.22).
171///
172/// Infallible by design. A source that cannot say what it is has nothing to
173/// report rather than an error to raise, and the header falls back to the plain
174/// form — failing a whole scan because its label did not render would be the
175/// tail wagging the dog.
176pub type ScanDescribeFuture<'a> = Pin<Box<dyn Future<Output = String> + Send + 'a>>;
177
178/// The boxed future an [`ScannedExcerptSource::roots`] returns (AF.1).
179pub type ScanRootsFuture<'a> =
180    Pin<Box<dyn Future<Output = Result<Vec<String>, String>> + Send + 'a>>;
181
182/// An async, off-keystroke-path producer of agenda rows.
183pub trait ScannedExcerptSource: Send + Sync + std::fmt::Debug {
184    /// Stable id of the producing plugin — the teardown key. Two producers
185    /// with the same id are the same plugin, so a reload replaces rather than
186    /// duplicates.
187    fn source_id(&self) -> u64;
188
189    /// File extensions this source wants offered, lowercased and without the
190    /// leading dot. Resolved once at registration and cached here, so the
191    /// walk's per-file test is a string compare rather than a guest call.
192    fn extensions(&self) -> &[String];
193
194    /// A minor mode this source wants activated on the agenda view.
195    ///
196    /// How a source acts on its own rows. The view's generic behaviour —
197    /// jump-to-source, `gr` — is the host's, because the host built the view
198    /// and is the only thing that can re-walk it; but the *semantics* of a
199    /// row belong to whoever produced it, and those need chords in a buffer
200    /// whose major is `multibuffer-mode`. No activation policy can say "the
201    /// buffer this provider just built", so the provider activates it and
202    /// this is the source naming what.
203    fn view_mode(&self) -> Option<&str> {
204        None
205    }
206
207    /// The paths this source wants scanned — each a FILE or a DIRECTORY (AF.1).
208    ///
209    /// Empty means "no opinion": the host uses the root it would have used, so
210    /// a source that does not implement this behaves exactly as before. That is
211    /// why it has a default and `extensions` does not — a source with no
212    /// extensions scans nothing and is a bug worth surfacing, while a source
213    /// with no roots is the ordinary unconfigured case.
214    ///
215    /// Called PER SCAN, unlike `extensions` and `view_mode`, which are facts
216    /// about the source and are cached at load. This answer comes from user
217    /// configuration and must follow a `:set` without a reload.
218    ///
219    /// An `Err` is logged and treated as empty: a source that cannot say where
220    /// to look should not be able to make the agenda scan nothing.
221    fn roots(&self) -> ScanRootsFuture<'_> {
222        Box::pin(async { Ok(Vec::new()) })
223    }
224
225    /// Drop per-scan state. Called once before the first file of a scan.
226    ///
227    /// An `Err` drops this source from the scan (its state is unknown, so its
228    /// rows would be untrustworthy) while every other source carries on.
229    ///
230    /// OA.11a: `args` is what the VIEW was opened with, passed through
231    /// **uninterpreted**. The host routes these; it does not read them. They
232    /// are how one source serves more than one scan — org's agenda dispatcher
233    /// names which custom command to run — and they are deliberately not the
234    /// provider view's `argument`, which is the root override and *is*
235    /// host-interpreted because the host does the walk.
236    ///
237    /// Called before [`Self::roots`], so a source that stashes its args here
238    /// has them for `roots`, every `scan`, and the generation key it returns.
239    /// Empty is the ordinary case: the default scan.
240    fn begin(&self, args: &[String]) -> ScanBeginFuture<'_>;
241
242    /// What this view IS, in the source's own words, for its headerline (OA.22).
243    ///
244    /// The host knows only how many rows it composed and how many files it
245    /// walked; it deliberately does not read `args` (see [`Self::begin`]). So an
246    /// agenda narrowed to one tag looks exactly like an unfiltered one — and
247    /// "you have no tasks" is the worst thing this view can say incorrectly.
248    ///
249    /// A short phrase naming the command, the span and any active filters. The
250    /// caller prefixes its own counts, so this must not repeat them. Empty
251    /// means "nothing worth saying" and the header keeps its plain form, which
252    /// is why the default is exactly that: a source with no view state to
253    /// report implements nothing.
254    ///
255    /// Called ONCE per scan, after `begin` — off the per-file path.
256    fn describe(&self, _args: &[String]) -> ScanDescribeFuture<'_> {
257        Box::pin(async { String::new() })
258    }
259
260    /// Scan one file. `text` is the file's contents, already read by the host
261    /// — the host must read it anyway to build the source `Document`, so it
262    /// reads once and hands the text over.
263    fn scan(&self, path: PathBuf, text: String) -> ScanFuture<'_>;
264
265    /// True when `path`'s extension is one this source claimed.
266    fn claims(&self, path: &std::path::Path) -> bool {
267        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
268            return false;
269        };
270        let lowered = ext.to_ascii_lowercase();
271        self.extensions().iter().any(|e| *e == lowered)
272    }
273}
274
275/// Runtime-mutable registry of [`ScannedExcerptSource`]s.
276#[derive(Default, Clone)]
277pub struct ScannedExcerptSourceRegistry {
278    sources: Vec<Arc<dyn ScannedExcerptSource>>,
279}
280
281impl std::fmt::Debug for ScannedExcerptSourceRegistry {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        f.debug_struct("ScannedExcerptSourceRegistry")
284            .field("sources", &self.sources.len())
285            .finish()
286    }
287}
288
289impl ScannedExcerptSourceRegistry {
290    /// An empty registry.
291    pub fn new() -> Self {
292        Self::default()
293    }
294
295    /// Register a producer. Idempotent per `source_id`: a re-register
296    /// (reload) replaces rather than accumulating a duplicate — otherwise
297    /// every `:plugin-reload` would double every row in the agenda.
298    pub fn register(&mut self, source: Arc<dyn ScannedExcerptSource>) {
299        let id = source.source_id();
300        self.sources.retain(|s| s.source_id() != id);
301        self.sources.push(source);
302    }
303
304    /// Unregister every producer for `source_id`; returns the count removed.
305    /// No-op when absent, per the teardown contract.
306    pub fn unregister(&mut self, source_id: u64) -> usize {
307        let before = self.sources.len();
308        self.sources.retain(|s| s.source_id() != source_id);
309        before - self.sources.len()
310    }
311
312    /// A snapshot of the registered producers.
313    pub fn sources(&self) -> Vec<Arc<dyn ScannedExcerptSource>> {
314        self.sources.clone()
315    }
316
317    /// Every minor mode a registered source wants on the agenda view.
318    ///
319    /// Deduplicated, and returned for EVERY registered source rather than
320    /// only the ones that contributed rows: a source's chords must be present
321    /// before the scan finishes, and a mode whose actions no-op off its own
322    /// rows is harmless where a mode that arrives late is a key that works on
323    /// the second try.
324    pub fn view_modes(&self) -> Vec<String> {
325        let mut out: Vec<String> = Vec::new();
326        for s in &self.sources {
327            if let Some(m) = s.view_mode()
328                && !out.iter().any(|e| e == m)
329            {
330                out.push(m.to_string());
331            }
332        }
333        out
334    }
335
336    /// Every source claiming `path`'s extension.
337    ///
338    /// The walk asks this per file. Returning the matching sources rather
339    /// than a bool means a file claimed by two producers is offered to both,
340    /// which is the honest answer when a markdown TODO scanner and a
341    /// checklist scanner both want `.md`.
342    pub fn claiming(&self, path: &std::path::Path) -> Vec<Arc<dyn ScannedExcerptSource>> {
343        self.sources
344            .iter()
345            .filter(|s| s.claims(path))
346            .cloned()
347            .collect()
348    }
349
350    /// True when no producer is registered.
351    pub fn is_empty(&self) -> bool {
352        self.sources.is_empty()
353    }
354
355    /// Number of registered producers.
356    pub fn len(&self) -> usize {
357        self.sources.len()
358    }
359}
360
361/// Boot-service handle. Register **and** look up with this exact alias (the
362/// `ServiceRegistry` TypeId rule).
363pub type ScannedExcerptSourceRegistryHandle = Arc<ArcSwap<ScannedExcerptSourceRegistry>>;
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use std::path::Path;
369
370    #[derive(Debug)]
371    struct Fake {
372        id: u64,
373        exts: Vec<String>,
374        view_mode: Option<String>,
375    }
376
377    impl Fake {
378        fn new(id: u64, exts: &[&str]) -> Self {
379            Self {
380                id,
381                exts: exts.iter().map(|e| e.to_string()).collect(),
382                view_mode: None,
383            }
384        }
385
386        fn with_view_mode(mut self, m: &str) -> Self {
387            self.view_mode = Some(m.to_string());
388            self
389        }
390    }
391
392    impl ScannedExcerptSource for Fake {
393        fn source_id(&self) -> u64 {
394            self.id
395        }
396        fn extensions(&self) -> &[String] {
397            &self.exts
398        }
399        fn view_mode(&self) -> Option<&str> {
400            self.view_mode.as_deref()
401        }
402        fn begin(&self, _args: &[String]) -> ScanBeginFuture<'_> {
403            Box::pin(async { Ok(()) })
404        }
405        fn scan(&self, _p: PathBuf, _t: String) -> ScanFuture<'_> {
406            Box::pin(async { Ok(ScanResult::default()) })
407        }
408    }
409
410    /// A reload must REPLACE its producer, not add a second one — otherwise
411    /// every `:plugin-reload` doubles every row in the agenda.
412    #[test]
413    fn re_registering_the_same_source_id_replaces_rather_than_duplicates() {
414        let mut r = ScannedExcerptSourceRegistry::new();
415        r.register(Arc::new(Fake::new(7, &["org"])));
416        r.register(Arc::new(Fake::new(7, &["org"])));
417        assert_eq!(r.len(), 1);
418        r.register(Arc::new(Fake::new(8, &["md"])));
419        assert_eq!(r.len(), 2);
420    }
421
422    #[test]
423    fn unregister_reports_what_it_removed_and_is_idempotent() {
424        let mut r = ScannedExcerptSourceRegistry::new();
425        r.register(Arc::new(Fake::new(7, &["org"])));
426        assert_eq!(r.unregister(7), 1);
427        assert_eq!(r.unregister(7), 0, "idempotent, per the teardown contract");
428        assert!(r.is_empty());
429    }
430
431    /// The property that keeps `.org` out of the host walk: which files get
432    /// offered is the SOURCE's answer, not the host's.
433    #[test]
434    fn only_sources_claiming_the_extension_are_offered_a_file() {
435        let mut r = ScannedExcerptSourceRegistry::new();
436        r.register(Arc::new(Fake::new(1, &["org"])));
437        r.register(Arc::new(Fake::new(2, &["md", "markdown"])));
438
439        let org = r.claiming(Path::new("/p/notes.org"));
440        assert_eq!(org.len(), 1);
441        assert_eq!(org[0].source_id(), 1);
442
443        assert_eq!(r.claiming(Path::new("/p/README.md")).len(), 1);
444        assert_eq!(r.claiming(Path::new("/p/main.rs")).len(), 0);
445    }
446
447    /// A file two producers both claim is offered to both — the honest
448    /// answer when a TODO scanner and a checklist scanner both want `.md`.
449    #[test]
450    fn a_file_claimed_by_two_sources_is_offered_to_both() {
451        let mut r = ScannedExcerptSourceRegistry::new();
452        r.register(Arc::new(Fake::new(1, &["md"])));
453        r.register(Arc::new(Fake::new(2, &["md"])));
454        assert_eq!(r.claiming(Path::new("/p/x.md")).len(), 2);
455    }
456
457    /// Every source's mode is offered, deduplicated — two org-shaped plugins
458    /// naming one mode must not activate it twice, and a source with no mode
459    /// must not contribute an entry.
460    #[test]
461    fn view_modes_are_collected_and_deduplicated() {
462        let mut r = ScannedExcerptSourceRegistry::new();
463        r.register(Arc::new(
464            Fake::new(1, &["org"]).with_view_mode("org-agenda-mode"),
465        ));
466        r.register(Arc::new(Fake::new(2, &["md"])));
467        r.register(Arc::new(
468            Fake::new(3, &["txt"]).with_view_mode("org-agenda-mode"),
469        ));
470        assert_eq!(r.view_modes(), vec!["org-agenda-mode".to_string()]);
471    }
472
473    /// `.ORG` is the same filetype as `.org`. The host lowercases the
474    /// extension; the loader lowercases what the guest declared.
475    #[test]
476    fn extension_matching_is_case_insensitive() {
477        let f = Fake::new(1, &["org"]);
478        assert!(f.claims(Path::new("/p/NOTES.ORG")));
479        assert!(f.claims(Path::new("/p/notes.org")));
480    }
481
482    /// An extensionless file (`Makefile`, a dotfile) matches nothing rather
483    /// than matching everything.
484    #[test]
485    fn a_file_with_no_extension_is_claimed_by_nobody() {
486        let f = Fake::new(1, &["org"]);
487        assert!(!f.claims(Path::new("/p/Makefile")));
488        assert!(!f.claims(Path::new("/p/.gitignore")));
489    }
490}