Skip to main content

termdoc_core/
event.rs

1//! The internal document model: an event stream, not a tree.
2//!
3//! See docs/DESIGN.md §2.2 for the reasoning. In short: a 10M-line log is never
4//! materialized, `Cow<'a, str>` borrows straight from the `mmap`, and backends stay as
5//! state machines that are trivial to test.
6//!
7//! ## Refinement over the original design
8//!
9//! The approved design separated `Event::Inline(Inline)` from `Event::Start(Block)`, with
10//! `Emphasis`/`Strong`/`Link` living inside `Inline`. That cannot be expressed: emphasis
11//! *wraps* content, so it needs an open and a close just like a paragraph does. With a
12//! single `Inline(Inline::Emphasis)` there is no way to know where it ends.
13//!
14//! Everything is therefore unified into `Start(Tag)` / `End(TagKind)` for every container
15//! —block-level or inline— with leaf events for whatever wraps nothing. This is
16//! `pulldown-cmark`'s proven model, and it makes mapping its output nearly mechanical.
17
18use std::borrow::Cow;
19use std::sync::Arc;
20
21/// Where an event came from in the source.
22///
23/// Without this there is no `--page`, no jumping from a search hit, and no navigable
24/// table of contents; adding it later would mean touching every reader, so it is here
25/// from day one.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub struct Span {
28    /// Byte range within the original source.
29    pub start: u64,
30    pub end: u64,
31    /// 1-based line, for formats that have lines.
32    pub line: Option<u32>,
33    /// 1-based page, for formats that have pages (PDF, EPUB).
34    pub page: Option<u32>,
35}
36
37impl Span {
38    pub const fn empty() -> Self {
39        Span {
40            start: 0,
41            end: 0,
42            line: None,
43            page: None,
44        }
45    }
46
47    pub const fn bytes(start: u64, end: u64) -> Self {
48        Span {
49            start,
50            end,
51            line: None,
52            page: None,
53        }
54    }
55
56    pub const fn at_line(start: u64, end: u64, line: u32) -> Self {
57        Span {
58            start,
59            end,
60            line: Some(line),
61            page: None,
62        }
63    }
64}
65
66/// An event together with its location.
67#[derive(Clone, Debug, PartialEq)]
68pub struct Spanned<T> {
69    pub node: T,
70    pub span: Span,
71}
72
73impl<T> Spanned<T> {
74    pub const fn new(node: T, span: Span) -> Self {
75        Spanned { node, span }
76    }
77
78    /// For readers that do not track positions precisely yet.
79    pub const fn bare(node: T) -> Self {
80        Spanned {
81            node,
82            span: Span::empty(),
83        }
84    }
85}
86
87/// The stream. Fallible per event: a document that turns out to be corrupt halfway
88/// through must not invalidate what was already emitted (see `Diagnostic` and
89/// docs/DESIGN.md §7).
90pub type Events<'a> = Box<dyn Iterator<Item = crate::Result<Spanned<Event<'a>>>> + 'a>;
91
92#[derive(Clone, Debug, PartialEq)]
93pub enum Event<'a> {
94    /// Opens a container, block-level or inline.
95    Start(Tag<'a>),
96    /// Closes the most recently opened container.
97    End(TagKind),
98
99    // --- Leaves ---
100    Text(Cow<'a, str>),
101    /// Inline code. A leaf because its content never carries formatting.
102    Code(Cow<'a, str>),
103    Image {
104        source: ImageSource<'a>,
105        alt: Cow<'a, str>,
106        dims: Option<(u32, u32)>,
107    },
108    Math {
109        inline: bool,
110        tex: Cow<'a, str>,
111    },
112    FootnoteRef(Cow<'a, str>),
113    Break(BreakKind),
114    Rule,
115    PageBreak,
116    /// A GFM task-list marker: `- [x]`.
117    TaskMarker(bool),
118
119    /// A recoverable problem. The stream continues: partial rendering beats total
120    /// failure.
121    Diagnostic(Diagnostic),
122}
123
124#[derive(Clone, Debug, PartialEq)]
125pub enum Tag<'a> {
126    // --- Blocks ---
127    /// Wraps the whole document; carries the metadata.
128    Document(Box<Metadata<'a>>),
129    Section {
130        level: u8,
131    },
132    Heading {
133        level: u8,
134        id: Option<Cow<'a, str>>,
135    },
136    Paragraph,
137    /// Text where the source's own line breaks are meaningful and must not be reflowed:
138    /// plain text, logs, command output. Distinct from `CodeBlock`, which also
139    /// highlights.
140    Preformatted,
141    List {
142        ordered: bool,
143        start: u64,
144        tight: bool,
145    },
146    ListItem {
147        marker: Marker,
148    },
149    Table {
150        align: Vec<Align>,
151    },
152    TableHead,
153    TableRow,
154    TableCell {
155        colspan: u16,
156        rowspan: u16,
157    },
158    CodeBlock {
159        lang: Option<Cow<'a, str>>,
160        filename: Option<Cow<'a, str>>,
161    },
162    BlockQuote {
163        attribution: Option<Cow<'a, str>>,
164    },
165    Admonition {
166        kind: AdmonitionKind,
167    },
168    Figure {
169        caption: Option<Cow<'a, str>>,
170    },
171    Footnote {
172        id: Cow<'a, str>,
173    },
174    DefinitionList,
175    DefinitionTerm,
176    DefinitionDetail,
177
178    // --- Inline containers ---
179    Emphasis,
180    Strong,
181    Strikethrough,
182    Underline,
183    Highlight,
184    SmallCaps,
185    Sub,
186    Super,
187    Link {
188        href: Cow<'a, str>,
189        title: Option<Cow<'a, str>>,
190    },
191}
192
193/// `Tag`'s discriminant, so `End` does not have to carry the payload again.
194#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
195pub enum TagKind {
196    Document,
197    Section,
198    Heading,
199    Paragraph,
200    Preformatted,
201    List,
202    ListItem,
203    Table,
204    TableHead,
205    TableRow,
206    TableCell,
207    CodeBlock,
208    BlockQuote,
209    Admonition,
210    Figure,
211    Footnote,
212    DefinitionList,
213    DefinitionTerm,
214    DefinitionDetail,
215    Emphasis,
216    Strong,
217    Strikethrough,
218    Underline,
219    Highlight,
220    SmallCaps,
221    Sub,
222    Super,
223    Link,
224}
225
226impl Tag<'_> {
227    pub fn kind(&self) -> TagKind {
228        match self {
229            Tag::Document(_) => TagKind::Document,
230            Tag::Section { .. } => TagKind::Section,
231            Tag::Heading { .. } => TagKind::Heading,
232            Tag::Paragraph => TagKind::Paragraph,
233            Tag::Preformatted => TagKind::Preformatted,
234            Tag::List { .. } => TagKind::List,
235            Tag::ListItem { .. } => TagKind::ListItem,
236            Tag::Table { .. } => TagKind::Table,
237            Tag::TableHead => TagKind::TableHead,
238            Tag::TableRow => TagKind::TableRow,
239            Tag::TableCell { .. } => TagKind::TableCell,
240            Tag::CodeBlock { .. } => TagKind::CodeBlock,
241            Tag::BlockQuote { .. } => TagKind::BlockQuote,
242            Tag::Admonition { .. } => TagKind::Admonition,
243            Tag::Figure { .. } => TagKind::Figure,
244            Tag::Footnote { .. } => TagKind::Footnote,
245            Tag::DefinitionList => TagKind::DefinitionList,
246            Tag::DefinitionTerm => TagKind::DefinitionTerm,
247            Tag::DefinitionDetail => TagKind::DefinitionDetail,
248            Tag::Emphasis => TagKind::Emphasis,
249            Tag::Strong => TagKind::Strong,
250            Tag::Strikethrough => TagKind::Strikethrough,
251            Tag::Underline => TagKind::Underline,
252            Tag::Highlight => TagKind::Highlight,
253            Tag::SmallCaps => TagKind::SmallCaps,
254            Tag::Sub => TagKind::Sub,
255            Tag::Super => TagKind::Super,
256            Tag::Link { .. } => TagKind::Link,
257        }
258    }
259}
260
261impl TagKind {
262    /// `true` when the container is block-level (forces a line break) rather than inline.
263    pub fn is_block(self) -> bool {
264        !matches!(
265            self,
266            TagKind::Emphasis
267                | TagKind::Strong
268                | TagKind::Strikethrough
269                | TagKind::Underline
270                | TagKind::Highlight
271                | TagKind::SmallCaps
272                | TagKind::Sub
273                | TagKind::Super
274                | TagKind::Link
275        )
276    }
277}
278
279#[derive(Clone, Copy, Debug, PartialEq, Eq)]
280pub enum BreakKind {
281    /// A break in the source that the layout may reflow into a space.
282    Soft,
283    /// A break that must be honored.
284    Hard,
285}
286
287#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
288pub enum Align {
289    #[default]
290    None,
291    Left,
292    Center,
293    Right,
294}
295
296#[derive(Clone, Copy, Debug, PartialEq, Eq)]
297pub enum AdmonitionKind {
298    Note,
299    Tip,
300    Important,
301    Warning,
302    Caution,
303}
304
305/// A list item's bullet. The layout picks the glyph based on `Fidelity`.
306#[derive(Clone, Copy, Debug, PartialEq, Eq)]
307pub enum Marker {
308    Bullet {
309        /// Nesting depth, used to rotate the glyph.
310        depth: u8,
311    },
312    Ordered {
313        number: u64,
314    },
315}
316
317/// A handle to an image; **never pixels**.
318///
319/// Decoding a 4000x3000 PNG only to print `[image: diagram]` is exactly the kind of waste
320/// this project cannot afford, so resolution is deferred until we know whether the
321/// backend can display graphics at all.
322#[derive(Clone)]
323pub enum ImageSource<'a> {
324    Path(Cow<'a, str>),
325    Bytes(Arc<[u8]>),
326    /// An entry inside a container (the ZIP of a DOCX/EPUB, a PDF object).
327    Entry {
328        container: Cow<'a, str>,
329        name: Cow<'a, str>,
330    },
331}
332
333impl std::fmt::Debug for ImageSource<'_> {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        match self {
336            ImageSource::Path(p) => write!(f, "Path({p:?})"),
337            ImageSource::Bytes(b) => write!(f, "Bytes({} bytes)", b.len()),
338            ImageSource::Entry { container, name } => {
339                write!(f, "Entry({container:?}, {name:?})")
340            }
341        }
342    }
343}
344
345impl PartialEq for ImageSource<'_> {
346    fn eq(&self, other: &Self) -> bool {
347        match (self, other) {
348            (ImageSource::Path(a), ImageSource::Path(b)) => a == b,
349            (ImageSource::Bytes(a), ImageSource::Bytes(b)) => a == b,
350            (
351                ImageSource::Entry {
352                    container: c1,
353                    name: n1,
354                },
355                ImageSource::Entry {
356                    container: c2,
357                    name: n2,
358                },
359            ) => c1 == c2 && n1 == n2,
360            _ => false,
361        }
362    }
363}
364
365#[derive(Clone, Debug, Default, PartialEq)]
366pub struct Metadata<'a> {
367    pub title: Option<Cow<'a, str>>,
368    pub authors: Vec<Cow<'a, str>>,
369    pub date: Option<Cow<'a, str>>,
370    pub language: Option<Cow<'a, str>>,
371    pub page_count: Option<u32>,
372    pub word_count: Option<u64>,
373    pub source_format: Option<crate::FormatId>,
374    pub encoding: Option<Cow<'a, str>>,
375    /// Format-specific extras, kept out of the shared model.
376    pub custom: Vec<(Cow<'a, str>, Cow<'a, str>)>,
377}
378
379#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
380pub enum Severity {
381    Info,
382    Warning,
383    Error,
384}
385
386/// A recoverable problem found while reading. It travels *inside* the stream so that
387/// rendering can continue; where it ends up —dimmed inline on a TTY, on stderr in a
388/// pipe— is the CLI's decision, not the reader's.
389#[derive(Clone, Debug, PartialEq)]
390pub struct Diagnostic {
391    pub severity: Severity,
392    pub message: String,
393}
394
395impl Diagnostic {
396    pub fn warning(message: impl Into<String>) -> Self {
397        Diagnostic {
398            severity: Severity::Warning,
399            message: message.into(),
400        }
401    }
402
403    pub fn error(message: impl Into<String>) -> Self {
404        Diagnostic {
405            severity: Severity::Error,
406            message: message.into(),
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn kind_round_trips_for_every_tag() {
417        // Catches adding a `Tag` while forgetting its `TagKind`.
418        let tags = [
419            Tag::Paragraph,
420            Tag::Preformatted,
421            Tag::Emphasis,
422            Tag::Strong,
423            Tag::Heading { level: 1, id: None },
424            Tag::List {
425                ordered: false,
426                start: 1,
427                tight: true,
428            },
429            Tag::Link {
430                href: "x".into(),
431                title: None,
432            },
433        ];
434        for t in tags {
435            let k = t.kind();
436            assert_eq!(k, t.kind(), "kind() must be stable");
437        }
438    }
439
440    #[test]
441    fn inline_is_not_a_block() {
442        assert!(!TagKind::Emphasis.is_block());
443        assert!(!TagKind::Link.is_block());
444        assert!(TagKind::Paragraph.is_block());
445        assert!(TagKind::Table.is_block());
446    }
447}