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}