Skip to main content

tdoc/
paragraph.rs

1//! Paragraph primitives that make up the [`Document`](crate::Document) tree.
2
3use crate::Span;
4use std::fmt;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7/// The structural role a [`Paragraph`] plays within a document.
8pub enum ParagraphType {
9    /// A plain text paragraph.
10    Text,
11    /// A level-1 heading (`<h1>`).
12    Header1,
13    /// A level-2 heading (`<h2>`).
14    Header2,
15    /// A level-3 heading (`<h3>`).
16    Header3,
17    /// A preformatted code block (`<pre>`).
18    CodeBlock,
19    /// An ordered list (`<ol>`) paragraph.
20    OrderedList,
21    /// An unordered (bulleted) list (`<ul>`).
22    UnorderedList,
23    /// A checklist (`<ul>` with checkbox items).
24    Checklist,
25    /// A block quote (`<blockquote>`).
26    Quote,
27    /// A tabular data block (`<table>`).
28    Table,
29    /// A horizontal rule / thematic break (`<hr>`).
30    HorizontalRule,
31    /// A definition list (`<dl>`) pairing terms with their descriptions.
32    DefinitionList,
33}
34
35impl fmt::Display for ParagraphType {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        let s = match self {
38            ParagraphType::Text => "Text",
39            ParagraphType::Header1 => "Header Lvl 1",
40            ParagraphType::Header2 => "Header Lvl 2",
41            ParagraphType::Header3 => "Header Lvl 3",
42            ParagraphType::CodeBlock => "Code Block",
43            ParagraphType::OrderedList => "Ordered List",
44            ParagraphType::UnorderedList => "Unordered List",
45            ParagraphType::Checklist => "Checklist",
46            ParagraphType::Quote => "Quote",
47            ParagraphType::Table => "Table",
48            ParagraphType::HorizontalRule => "Horizontal Rule",
49            ParagraphType::DefinitionList => "Definition List",
50        };
51        write!(f, "{}", s)
52    }
53}
54
55impl ParagraphType {
56    /// Returns `true` if paragraphs of this type cannot contain child paragraphs.
57    pub fn is_leaf(&self) -> bool {
58        matches!(
59            self,
60            ParagraphType::Text
61                | ParagraphType::Header1
62                | ParagraphType::Header2
63                | ParagraphType::Header3
64                | ParagraphType::CodeBlock
65                | ParagraphType::HorizontalRule
66        )
67    }
68
69    /// Returns the canonical HTML tag used when serializing this paragraph type.
70    pub fn html_tag(&self) -> &'static str {
71        match self {
72            ParagraphType::Text => "p",
73            ParagraphType::Header1 => "h1",
74            ParagraphType::Header2 => "h2",
75            ParagraphType::Header3 => "h3",
76            ParagraphType::CodeBlock => "pre",
77            ParagraphType::OrderedList => "ol",
78            ParagraphType::UnorderedList => "ul",
79            ParagraphType::Checklist => "ul",
80            ParagraphType::Quote => "blockquote",
81            ParagraphType::Table => "table",
82            ParagraphType::HorizontalRule => "hr",
83            ParagraphType::DefinitionList => "dl",
84        }
85    }
86
87    /// Attempts to map an HTML tag back to a [`ParagraphType`].
88    pub fn from_html_tag(tag: &str) -> Option<Self> {
89        match tag {
90            "p" => Some(ParagraphType::Text),
91            "h1" => Some(ParagraphType::Header1),
92            "h2" => Some(ParagraphType::Header2),
93            "h3" => Some(ParagraphType::Header3),
94            "pre" => Some(ParagraphType::CodeBlock),
95            "ol" => Some(ParagraphType::OrderedList),
96            "ul" => Some(ParagraphType::UnorderedList),
97            "blockquote" => Some(ParagraphType::Quote),
98            "table" => Some(ParagraphType::Table),
99            "hr" => Some(ParagraphType::HorizontalRule),
100            "dl" => Some(ParagraphType::DefinitionList),
101            _ => None,
102        }
103    }
104
105    /// Returns `true` if the current paragraph type can be closed by the
106    /// provided closing type (derived from the tag name).
107    pub fn matches_closing_tag(self, closing: ParagraphType) -> bool {
108        if self == closing {
109            return true;
110        }
111
112        matches!(
113            (self, closing),
114            (ParagraphType::Checklist, ParagraphType::UnorderedList)
115        )
116    }
117}
118
119#[derive(Debug, Clone, PartialEq)]
120/// A node in the document tree representing text, lists, headings, or quotes.
121///
122/// Paragraphs can contain nested paragraphs (for quotes or nested lists), inline
123/// [`Span`](crate::Span) content, or list entries depending on their
124/// [`ParagraphType`]. Modeling paragraphs as an enum ensures only valid
125/// combinations of data are representable (e.g. lists always carry entries).
126///
127/// # Examples
128///
129/// ```
130/// use tdoc::{Paragraph, ParagraphType, Span};
131///
132/// // Simple paragraph with inline content.
133/// let text = Paragraph::new_text().with_content(vec![Span::new_text("Hello!")]);
134/// assert!(text.is_leaf());
135///
136/// // List paragraphs manage their items via entries.
137/// let mut list = Paragraph::new_unordered_list();
138/// list.add_list_item(vec![Paragraph::new_text().with_content(vec![Span::new_text("One")])]);
139/// list.add_list_item(vec![Paragraph::new_text().with_content(vec![Span::new_text("Two")])]);
140/// assert!(!list.is_leaf());
141/// ```
142pub enum Paragraph {
143    /// A plain text paragraph with inline spans.
144    Text { content: Vec<Span> },
145    /// A level-1 heading paragraph.
146    Header1 { content: Vec<Span> },
147    /// A level-2 heading paragraph.
148    Header2 { content: Vec<Span> },
149    /// A level-3 heading paragraph.
150    Header3 { content: Vec<Span> },
151    /// A preformatted code block paragraph.
152    CodeBlock { content: Vec<Span> },
153    /// An ordered list paragraph that owns list entries.
154    OrderedList { entries: Vec<Vec<Paragraph>> },
155    /// An unordered/bulleted list paragraph.
156    UnorderedList { entries: Vec<Vec<Paragraph>> },
157    /// A checklist paragraph with checklist items.
158    Checklist { items: Vec<ChecklistItem> },
159    /// A block quote paragraph that contains nested paragraphs.
160    Quote { children: Vec<Paragraph> },
161    /// A table paragraph composed of rows of cells.
162    Table { rows: Vec<TableRow> },
163    /// A horizontal rule / thematic break. Carries no content.
164    HorizontalRule,
165    /// A definition list pairing one or more terms with their descriptions.
166    DefinitionList { items: Vec<DefinitionItem> },
167}
168
169impl Paragraph {
170    /// Creates a paragraph with the provided [`ParagraphType`].
171    pub fn new(paragraph_type: ParagraphType) -> Self {
172        match paragraph_type {
173            ParagraphType::Text => Self::new_text(),
174            ParagraphType::Header1 => Self::new_header1(),
175            ParagraphType::Header2 => Self::new_header2(),
176            ParagraphType::Header3 => Self::new_header3(),
177            ParagraphType::CodeBlock => Self::new_code_block(),
178            ParagraphType::OrderedList => Self::new_ordered_list(),
179            ParagraphType::UnorderedList => Self::new_unordered_list(),
180            ParagraphType::Checklist => Self::new_checklist(),
181            ParagraphType::Quote => Self::new_quote(),
182            ParagraphType::Table => Self::new_table(),
183            ParagraphType::HorizontalRule => Self::new_horizontal_rule(),
184            ParagraphType::DefinitionList => Self::new_definition_list(),
185        }
186    }
187
188    /// Convenience constructor for [`ParagraphType::Text`].
189    pub fn new_text() -> Self {
190        Self::Text {
191            content: Vec::new(),
192        }
193    }
194
195    /// Convenience constructor for [`ParagraphType::Header1`].
196    pub fn new_header1() -> Self {
197        Self::Header1 {
198            content: Vec::new(),
199        }
200    }
201
202    /// Convenience constructor for [`ParagraphType::Header2`].
203    pub fn new_header2() -> Self {
204        Self::Header2 {
205            content: Vec::new(),
206        }
207    }
208
209    /// Convenience constructor for [`ParagraphType::Header3`].
210    pub fn new_header3() -> Self {
211        Self::Header3 {
212            content: Vec::new(),
213        }
214    }
215
216    /// Convenience constructor for [`ParagraphType::CodeBlock`].
217    pub fn new_code_block() -> Self {
218        Self::CodeBlock {
219            content: Vec::new(),
220        }
221    }
222
223    /// Convenience constructor for [`ParagraphType::OrderedList`].
224    pub fn new_ordered_list() -> Self {
225        Self::OrderedList {
226            entries: Vec::new(),
227        }
228    }
229
230    /// Convenience constructor for [`ParagraphType::UnorderedList`].
231    pub fn new_unordered_list() -> Self {
232        Self::UnorderedList {
233            entries: Vec::new(),
234        }
235    }
236
237    /// Convenience constructor for [`ParagraphType::Checklist`].
238    pub fn new_checklist() -> Self {
239        Self::Checklist { items: Vec::new() }
240    }
241
242    /// Convenience constructor for [`ParagraphType::Quote`].
243    pub fn new_quote() -> Self {
244        Self::Quote {
245            children: Vec::new(),
246        }
247    }
248
249    /// Convenience constructor for [`ParagraphType::Table`].
250    pub fn new_table() -> Self {
251        Self::Table { rows: Vec::new() }
252    }
253
254    /// Convenience constructor for [`ParagraphType::HorizontalRule`].
255    pub fn new_horizontal_rule() -> Self {
256        Self::HorizontalRule
257    }
258
259    /// Convenience constructor for [`ParagraphType::DefinitionList`].
260    pub fn new_definition_list() -> Self {
261        Self::DefinitionList { items: Vec::new() }
262    }
263
264    /// Returns the [`ParagraphType`] of the current paragraph.
265    pub fn paragraph_type(&self) -> ParagraphType {
266        match self {
267            Paragraph::Text { .. } => ParagraphType::Text,
268            Paragraph::Header1 { .. } => ParagraphType::Header1,
269            Paragraph::Header2 { .. } => ParagraphType::Header2,
270            Paragraph::Header3 { .. } => ParagraphType::Header3,
271            Paragraph::CodeBlock { .. } => ParagraphType::CodeBlock,
272            Paragraph::OrderedList { .. } => ParagraphType::OrderedList,
273            Paragraph::UnorderedList { .. } => ParagraphType::UnorderedList,
274            Paragraph::Checklist { .. } => ParagraphType::Checklist,
275            Paragraph::Quote { .. } => ParagraphType::Quote,
276            Paragraph::Table { .. } => ParagraphType::Table,
277            Paragraph::HorizontalRule => ParagraphType::HorizontalRule,
278            Paragraph::DefinitionList { .. } => ParagraphType::DefinitionList,
279        }
280    }
281
282    /// Returns `true` if this paragraph cannot contain nested paragraphs.
283    pub fn is_leaf(&self) -> bool {
284        self.paragraph_type().is_leaf()
285    }
286
287    /// Returns the inline content for leaf paragraphs, or an empty slice otherwise.
288    pub fn content(&self) -> &[Span] {
289        match self {
290            Paragraph::Text { content }
291            | Paragraph::Header1 { content }
292            | Paragraph::Header2 { content }
293            | Paragraph::Header3 { content }
294            | Paragraph::CodeBlock { content } => content,
295            _ => &[],
296        }
297    }
298
299    /// Returns mutable inline content for leaf paragraphs.
300    pub fn content_mut(&mut self) -> &mut Vec<Span> {
301        match self {
302            Paragraph::Text { content }
303            | Paragraph::Header1 { content }
304            | Paragraph::Header2 { content }
305            | Paragraph::Header3 { content }
306            | Paragraph::CodeBlock { content } => content,
307            _ => panic!("only leaf paragraphs contain inline content"),
308        }
309    }
310
311    /// Replaces the inline content of the paragraph.
312    pub fn with_content(self, content: Vec<Span>) -> Self {
313        match self {
314            Paragraph::Text { .. } => Paragraph::Text { content },
315            Paragraph::Header1 { .. } => Paragraph::Header1 { content },
316            Paragraph::Header2 { .. } => Paragraph::Header2 { content },
317            Paragraph::Header3 { .. } => Paragraph::Header3 { content },
318            Paragraph::CodeBlock { .. } => Paragraph::CodeBlock { content },
319            _ => panic!("only leaf paragraphs can hold inline content"),
320        }
321    }
322
323    /// Returns the child paragraphs for quote nodes (or an empty slice).
324    pub fn children(&self) -> &[Paragraph] {
325        match self {
326            Paragraph::Quote { children } => children,
327            _ => &[],
328        }
329    }
330
331    /// Returns mutable child paragraphs for quote nodes.
332    pub fn children_mut(&mut self) -> &mut Vec<Paragraph> {
333        match self {
334            Paragraph::Quote { children } => children,
335            _ => panic!("only block quotes hold child paragraphs"),
336        }
337    }
338
339    /// Replaces the paragraph's child paragraphs.
340    pub fn with_children(self, children: Vec<Paragraph>) -> Self {
341        match self {
342            Paragraph::Quote { .. } => Paragraph::Quote { children },
343            _ => panic!("only block quotes can hold child paragraphs"),
344        }
345    }
346
347    /// Appends a child paragraph (used for quotes or nested structures).
348    pub fn add_child(&mut self, child: Paragraph) {
349        self.children_mut().push(child);
350    }
351
352    /// Returns the list entries for list paragraphs (or an empty slice).
353    pub fn entries(&self) -> &[Vec<Paragraph>] {
354        match self {
355            Paragraph::OrderedList { entries } | Paragraph::UnorderedList { entries } => entries,
356            _ => &[],
357        }
358    }
359
360    /// Returns mutable access to list entries for list paragraphs.
361    pub fn entries_mut(&mut self) -> &mut Vec<Vec<Paragraph>> {
362        match self {
363            Paragraph::OrderedList { entries } | Paragraph::UnorderedList { entries } => entries,
364            _ => panic!("only list paragraphs can hold entries"),
365        }
366    }
367
368    /// Replaces the paragraph's list entries.
369    pub fn with_entries(self, entries: Vec<Vec<Paragraph>>) -> Self {
370        match self {
371            Paragraph::OrderedList { .. } => Paragraph::OrderedList { entries },
372            Paragraph::UnorderedList { .. } => Paragraph::UnorderedList { entries },
373            _ => panic!("only list paragraphs can hold entries"),
374        }
375    }
376
377    /// Appends a single list item built from nested paragraphs.
378    pub fn add_list_item(&mut self, item: Vec<Paragraph>) {
379        self.entries_mut().push(item);
380    }
381
382    /// Returns the checklist items for checklist paragraphs (or an empty slice).
383    pub fn checklist_items(&self) -> &[ChecklistItem] {
384        match self {
385            Paragraph::Checklist { items } => items,
386            _ => &[],
387        }
388    }
389
390    /// Returns mutable access to checklist items for checklist paragraphs.
391    pub fn checklist_items_mut(&mut self) -> &mut Vec<ChecklistItem> {
392        match self {
393            Paragraph::Checklist { items } => items,
394            _ => panic!("only checklist paragraphs can hold checklist items"),
395        }
396    }
397
398    /// Replaces the paragraph's checklist items.
399    pub fn with_checklist_items(self, items: Vec<ChecklistItem>) -> Self {
400        match self {
401            Paragraph::Checklist { .. } => Paragraph::Checklist { items },
402            _ => panic!("only checklist paragraphs can hold checklist items"),
403        }
404    }
405
406    /// Appends a single checklist item. Only valid for checklist paragraphs.
407    pub fn add_checklist_item(&mut self, item: ChecklistItem) {
408        self.checklist_items_mut().push(item);
409    }
410
411    /// Returns the table rows for table paragraphs (or an empty slice).
412    pub fn rows(&self) -> &[TableRow] {
413        match self {
414            Paragraph::Table { rows } => rows,
415            _ => &[],
416        }
417    }
418
419    /// Returns mutable access to table rows for table paragraphs.
420    pub fn rows_mut(&mut self) -> &mut Vec<TableRow> {
421        match self {
422            Paragraph::Table { rows } => rows,
423            _ => panic!("only table paragraphs can hold rows"),
424        }
425    }
426
427    /// Replaces the paragraph's table rows.
428    pub fn with_rows(self, rows: Vec<TableRow>) -> Self {
429        match self {
430            Paragraph::Table { .. } => Paragraph::Table { rows },
431            _ => panic!("only table paragraphs can hold rows"),
432        }
433    }
434
435    /// Appends a single row to a table paragraph.
436    pub fn add_row(&mut self, row: TableRow) {
437        self.rows_mut().push(row);
438    }
439
440    /// Returns the items of a definition-list paragraph (or an empty slice).
441    pub fn definition_items(&self) -> &[DefinitionItem] {
442        match self {
443            Paragraph::DefinitionList { items } => items,
444            _ => &[],
445        }
446    }
447
448    /// Returns mutable access to the items of a definition-list paragraph.
449    pub fn definition_items_mut(&mut self) -> &mut Vec<DefinitionItem> {
450        match self {
451            Paragraph::DefinitionList { items } => items,
452            _ => panic!("only definition-list paragraphs can hold definition items"),
453        }
454    }
455
456    /// Replaces the paragraph's definition-list items.
457    pub fn with_definition_items(self, items: Vec<DefinitionItem>) -> Self {
458        match self {
459            Paragraph::DefinitionList { .. } => Paragraph::DefinitionList { items },
460            _ => panic!("only definition-list paragraphs can hold definition items"),
461        }
462    }
463
464    /// Appends a single item to a definition-list paragraph.
465    pub fn add_definition_item(&mut self, item: DefinitionItem) {
466        self.definition_items_mut().push(item);
467    }
468}
469
470#[derive(Debug, Clone, PartialEq, Default)]
471/// A single row inside a [`Paragraph::Table`].
472///
473/// Rows carry an ordered list of [`TableCell`]s. The same row may mix header
474/// and data cells freely; that distinction lives on the individual cell.
475pub struct TableRow {
476    pub cells: Vec<TableCell>,
477}
478
479impl TableRow {
480    /// Creates an empty table row.
481    pub fn new() -> Self {
482        Self::default()
483    }
484
485    /// Replaces the row's cells.
486    pub fn with_cells(mut self, cells: Vec<TableCell>) -> Self {
487        self.cells = cells;
488        self
489    }
490
491    /// Appends a single cell to the row.
492    pub fn add_cell(&mut self, cell: TableCell) {
493        self.cells.push(cell);
494    }
495}
496
497#[derive(Debug, Clone, PartialEq)]
498/// A single cell inside a [`TableRow`].
499///
500/// Cells hold inline [`Span`](crate::Span) content and a flag distinguishing
501/// header cells (`<th>`) from data cells (`<td>`). Tables use left-aligned
502/// content by default and do not carry any explicit alignment information.
503pub struct TableCell {
504    pub is_header: bool,
505    pub content: Vec<Span>,
506}
507
508impl TableCell {
509    /// Creates a new cell with the given header flag.
510    pub fn new(is_header: bool) -> Self {
511        Self {
512            is_header,
513            content: Vec::new(),
514        }
515    }
516
517    /// Creates a data cell (`<td>`) with empty content.
518    pub fn new_data() -> Self {
519        Self::new(false)
520    }
521
522    /// Creates a header cell (`<th>`) with empty content.
523    pub fn new_header() -> Self {
524        Self::new(true)
525    }
526
527    /// Replaces the inline content of the cell.
528    pub fn with_content(mut self, content: Vec<Span>) -> Self {
529        self.content = content;
530        self
531    }
532}
533
534#[derive(Debug, Clone, PartialEq)]
535/// Represents a single item within a checklist.
536///
537/// Checklist items contain inline [`Span`](crate::Span) content along with
538/// optional nested checklist items. Nested content is restricted to other
539/// checklist items.
540pub struct ChecklistItem {
541    pub checked: bool,
542    pub content: Vec<Span>,
543    pub children: Vec<ChecklistItem>,
544}
545
546impl ChecklistItem {
547    /// Creates a new checklist item with the provided completion state.
548    pub fn new(checked: bool) -> Self {
549        Self {
550            checked,
551            content: Vec::new(),
552            children: Vec::new(),
553        }
554    }
555
556    /// Replaces the inline content of the checklist item.
557    pub fn with_content(mut self, content: Vec<Span>) -> Self {
558        self.content = content;
559        self
560    }
561
562    /// Replaces the nested checklist children.
563    pub fn with_children(mut self, children: Vec<ChecklistItem>) -> Self {
564        self.children = children;
565        self
566    }
567
568    /// Appends a nested checklist item.
569    pub fn add_child(&mut self, child: ChecklistItem) {
570        self.children.push(child);
571    }
572}
573
574#[derive(Debug, Clone, PartialEq, Default)]
575/// A single group within a [`Paragraph::DefinitionList`].
576///
577/// Each item pairs one or more terms (`<dt>`, inline content) with a single
578/// definition (`<dd>`) made of block [`Paragraph`]s. When a source document
579/// lists several definitions for the same term(s), they are folded into this
580/// one definition as separate paragraphs — HTML `<dd>`s and Markdown `:` lines
581/// alike. Grouping several terms together mirrors how definition lists are
582/// authored in both HTML and Markdown, where consecutive terms share the
583/// definition that follows them.
584pub struct DefinitionItem {
585    /// The terms being defined. Each term holds inline [`Span`] content.
586    pub terms: Vec<Vec<Span>>,
587    /// The definition for the terms, as a list of block [`Paragraph`]s. Multiple
588    /// source definitions are represented as consecutive paragraphs here.
589    pub definition: Vec<Paragraph>,
590}
591
592impl DefinitionItem {
593    /// Creates an empty definition item with no terms or definition.
594    pub fn new() -> Self {
595        Self::default()
596    }
597
598    /// Replaces the item's terms.
599    pub fn with_terms(mut self, terms: Vec<Vec<Span>>) -> Self {
600        self.terms = terms;
601        self
602    }
603
604    /// Replaces the item's definition (its block paragraphs).
605    pub fn with_definition(mut self, definition: Vec<Paragraph>) -> Self {
606        self.definition = definition;
607        self
608    }
609
610    /// Appends a single term (inline content).
611    pub fn add_term(&mut self, term: Vec<Span>) {
612        self.terms.push(term);
613    }
614
615    /// Appends a single paragraph to the item's definition.
616    pub fn add_definition_paragraph(&mut self, paragraph: Paragraph) {
617        self.definition.push(paragraph);
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_paragraph_type_display() {
627        assert_eq!(format!("{}", ParagraphType::Text), "Text");
628        assert_eq!(format!("{}", ParagraphType::Header1), "Header Lvl 1");
629    }
630
631    #[test]
632    fn test_html_tag_conversion() {
633        assert_eq!(ParagraphType::Text.html_tag(), "p");
634        assert_eq!(ParagraphType::from_html_tag("p"), Some(ParagraphType::Text));
635        assert_eq!(ParagraphType::CodeBlock.html_tag(), "pre");
636        assert_eq!(
637            ParagraphType::from_html_tag("pre"),
638            Some(ParagraphType::CodeBlock)
639        );
640        assert_eq!(ParagraphType::from_html_tag("div"), None);
641    }
642
643    #[test]
644    fn test_is_leaf() {
645        assert!(ParagraphType::Text.is_leaf());
646        assert!(ParagraphType::Header1.is_leaf());
647        assert!(ParagraphType::CodeBlock.is_leaf());
648        assert!(!ParagraphType::OrderedList.is_leaf());
649        assert!(!ParagraphType::Quote.is_leaf());
650    }
651
652    #[test]
653    fn test_paragraph_creation() {
654        let p = Paragraph::new_text().with_content(vec![Span::new_text("Hello")]);
655
656        assert_eq!(p.paragraph_type(), ParagraphType::Text);
657        assert_eq!(p.content().len(), 1);
658        assert_eq!(p.content()[0].text, "Hello");
659    }
660}