Skip to main content

basalt_tui/note_editor/
ast.rs

1use crate::note_editor::rich_text::RichText;
2
3#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
4pub enum HeadingLevel {
5    H1 = 1,
6    H2,
7    H3,
8    H4,
9    H5,
10    H6,
11}
12
13impl From<pulldown_cmark::HeadingLevel> for HeadingLevel {
14    fn from(value: pulldown_cmark::HeadingLevel) -> Self {
15        match value {
16            pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1,
17            pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2,
18            pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3,
19            pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4,
20            pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5,
21            pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6,
22        }
23    }
24}
25
26/// The Obsidian callout types. GitHub's `important`/`caution` are aliases of
27/// `tip`/`warning`, matching Obsidian.
28#[derive(Clone, Debug, PartialEq)]
29pub enum BlockQuoteKind {
30    Note,
31    Abstract,
32    Info,
33    Todo,
34    Tip,
35    Success,
36    Question,
37    Warning,
38    Failure,
39    Danger,
40    Bug,
41    Example,
42    Quote,
43}
44
45impl From<pulldown_cmark::BlockQuoteKind> for BlockQuoteKind {
46    fn from(value: pulldown_cmark::BlockQuoteKind) -> Self {
47        use pulldown_cmark::BlockQuoteKind as Gfm;
48        match value {
49            Gfm::Note => BlockQuoteKind::Note,
50            Gfm::Tip | Gfm::Important => BlockQuoteKind::Tip,
51            Gfm::Warning | Gfm::Caution => BlockQuoteKind::Warning,
52        }
53    }
54}
55
56impl BlockQuoteKind {
57    /// Resolves a callout type name (including Obsidian aliases). Unknown types
58    /// fall back to [`Note`](BlockQuoteKind::Note), as in Obsidian.
59    fn from_name(name: &str) -> Self {
60        use BlockQuoteKind::*;
61        match name.trim().to_ascii_lowercase().as_str() {
62            "abstract" | "summary" | "tldr" => Abstract,
63            "info" => Info,
64            "todo" => Todo,
65            "tip" | "hint" | "important" => Tip,
66            "success" | "check" | "done" => Success,
67            "question" | "help" | "faq" => Question,
68            "warning" | "caution" | "attention" => Warning,
69            "failure" | "fail" | "missing" => Failure,
70            "danger" | "error" => Danger,
71            "bug" => Bug,
72            "example" => Example,
73            "quote" | "cite" => Quote,
74            _ => Note,
75        }
76    }
77}
78
79pub struct CalloutMarker {
80    pub kind: BlockQuoteKind,
81    pub title: Option<String>,
82}
83
84/// Parses a callout marker (`[!note]`, `[!note]- Title`) from a quote's first
85/// line. Covers Obsidian's fold markers (`-`/`+`, accepted but not yet acted on)
86/// and custom titles, which `pulldown_cmark` does not recognise.
87pub fn parse_callout_marker(line: &str) -> Option<CalloutMarker> {
88    let rest = line.trim_start().strip_prefix("[!")?;
89    let close = rest.find(']')?;
90    let kind = BlockQuoteKind::from_name(&rest[..close]);
91    let tail = &rest[close + 1..];
92    let title = tail.strip_prefix(['-', '+']).unwrap_or(tail).trim();
93    Some(CalloutMarker {
94        kind,
95        title: (!title.is_empty()).then(|| title.to_string()),
96    })
97}
98
99/// Denotes whether a list is ordered or unordered.
100#[derive(Clone, Debug, PartialEq)]
101pub enum ItemKind {
102    /// An ordered list item (e.g., `1. item`), storing the numeric index.
103    Ordered(u64),
104    /// An unordered list item (e.g., `- item`).
105    Unordered,
106}
107
108/// Represents the variant of a list or task item (checked, unchecked, etc.).
109#[derive(Clone, Debug, PartialEq)]
110pub enum TaskKind {
111    /// A checkbox item that is marked as done using `- [x]`.
112    Checked,
113    /// A checkbox item that is unchecked using `- [ ]`.
114    Unchecked,
115    /// A checkbox item that is checked, but not explicitly recognized as
116    /// `Checked` (e.g., `- [?]`).
117    LooselyChecked,
118}
119
120/// Column alignment for a table, derived from the delimiter row (e.g. `:---:`).
121#[derive(Clone, Copy, Debug, PartialEq)]
122pub enum Alignment {
123    /// No explicit alignment; rendered left aligned.
124    None,
125    /// Left aligned (`:---`).
126    Left,
127    /// Centered (`:---:`).
128    Center,
129    /// Right aligned (`---:`).
130    Right,
131}
132
133impl From<pulldown_cmark::Alignment> for Alignment {
134    fn from(value: pulldown_cmark::Alignment) -> Self {
135        match value {
136            pulldown_cmark::Alignment::None => Alignment::None,
137            pulldown_cmark::Alignment::Left => Alignment::Left,
138            pulldown_cmark::Alignment::Center => Alignment::Center,
139            pulldown_cmark::Alignment::Right => Alignment::Right,
140        }
141    }
142}
143
144pub type SourceRange<Idx> = std::ops::Range<Idx>;
145
146/// The Markdown AST node enumeration.
147#[derive(Clone, Debug, PartialEq)]
148pub enum Node {
149    Heading {
150        level: HeadingLevel,
151        text: RichText,
152        source_range: SourceRange<usize>,
153    },
154    Paragraph {
155        text: RichText,
156        source_range: SourceRange<usize>,
157    },
158    CodeBlock {
159        lang: Option<String>,
160        text: RichText,
161        source_range: SourceRange<usize>,
162    },
163    BlockQuote {
164        kind: Option<BlockQuoteKind>,
165        /// Custom callout title (Obsidian `> [!note] Title`); `None` falls back
166        /// to the kind's own label.
167        title: Option<String>,
168        nodes: Vec<Node>,
169        source_range: SourceRange<usize>,
170    },
171    List {
172        nodes: Vec<Node>,
173        source_range: SourceRange<usize>,
174    },
175    Item {
176        kind: ItemKind,
177        nodes: Vec<Node>,
178        source_range: SourceRange<usize>,
179    },
180    Task {
181        kind: TaskKind,
182        nodes: Vec<Node>,
183        source_range: SourceRange<usize>,
184    },
185    /// A GFM table with a header row and zero or more body rows. Each cell is the [`RichText`]
186    /// between two pipes; `alignments` holds one [`Alignment`] per column.
187    Table {
188        alignments: Vec<Alignment>,
189        head: Vec<RichText>,
190        rows: Vec<Vec<RichText>>,
191        source_range: SourceRange<usize>,
192    },
193}
194
195impl Node {
196    pub fn source_range(&self) -> &SourceRange<usize> {
197        match self {
198            Self::Heading { source_range, .. }
199            | Self::CodeBlock { source_range, .. }
200            | Self::Paragraph { source_range, .. }
201            | Self::List { source_range, .. }
202            | Self::BlockQuote { source_range, .. }
203            | Self::Item { source_range, .. }
204            | Self::Task { source_range, .. }
205            | Self::Table { source_range, .. } => source_range,
206        }
207    }
208
209    pub fn set_source_range(&mut self, new_range: SourceRange<usize>) {
210        match self {
211            Self::Heading { source_range, .. }
212            | Self::CodeBlock { source_range, .. }
213            | Self::Paragraph { source_range, .. }
214            | Self::List { source_range, .. }
215            | Self::BlockQuote { source_range, .. }
216            | Self::Item { source_range, .. }
217            | Self::Task { source_range, .. }
218            | Self::Table { source_range, .. } => *source_range = new_range,
219        }
220    }
221
222    pub fn rich_text(&self) -> Option<&RichText> {
223        match self {
224            Self::Heading { text, .. }
225            | Self::Paragraph { text, .. }
226            | Self::CodeBlock { text, .. } => Some(text),
227            _ => None,
228        }
229    }
230
231    pub fn children_as_mut(&mut self) -> Option<&mut Vec<Self>> {
232        match self {
233            Self::List { nodes, .. }
234            | Self::Item { nodes, .. }
235            | Self::Task { nodes, .. }
236            | Self::BlockQuote { nodes, .. } => Some(nodes),
237            _ => None,
238        }
239    }
240}
241
242pub fn nodes_to_sexp(nodes: &[Node], indent_level: usize) -> String {
243    nodes
244        .iter()
245        .map(|node| node_to_sexp(node, indent_level))
246        .collect::<Vec<_>>()
247        .join("\n")
248}
249
250pub fn node_to_sexp(node: &Node, indent_level: usize) -> String {
251    let indent_increment = 2;
252
253    match node {
254        Node::Heading {
255            level,
256            text,
257            source_range,
258        } => {
259            format!(
260                "{:indent$}(heading {:?} @{:?}\n{})",
261                "",
262                level,
263                source_range,
264                rich_text_to_sexp(text, indent_level + indent_increment),
265                indent = indent_level
266            )
267        }
268        Node::Paragraph { text, source_range } => {
269            format!(
270                "{:indent$}(paragraph @{:?}\n{})",
271                "",
272                source_range,
273                rich_text_to_sexp(text, indent_level + indent_increment),
274                indent = indent_level
275            )
276        }
277        Node::BlockQuote {
278            kind,
279            title,
280            nodes,
281            source_range,
282        } => {
283            format!(
284                "{:indent$}(blockquote {:?} {:?} @{:?}\n{})",
285                "",
286                kind,
287                title,
288                source_range,
289                nodes_to_sexp(nodes, indent_level + indent_increment),
290                indent = indent_level
291            )
292        }
293        Node::CodeBlock {
294            lang,
295            text,
296            source_range,
297        } => {
298            format!(
299                "{:indent$}(codeblock {} @{:?}\n{})",
300                "",
301                lang.clone().unwrap_or(String::new()),
302                source_range,
303                rich_text_to_sexp(text, indent_level + indent_increment),
304                indent = indent_level,
305            )
306        }
307        Node::List {
308            nodes,
309            source_range,
310        } => {
311            format!(
312                "{:indent$}(list @{:?}\n{})",
313                "",
314                source_range,
315                nodes_to_sexp(nodes, indent_level + indent_increment),
316                indent = indent_level
317            )
318        }
319        Node::Item {
320            kind,
321            nodes,
322            source_range,
323        } => {
324            format!(
325                "{:indent$}(item {:?} @{:?}\n{})",
326                "",
327                kind,
328                source_range,
329                nodes_to_sexp(nodes, indent_level + indent_increment),
330                indent = indent_level
331            )
332        }
333        Node::Task {
334            kind,
335            nodes,
336            source_range,
337        } => {
338            format!(
339                "{:indent$}(task {:?} @{:?}\n{})",
340                "",
341                kind,
342                source_range,
343                nodes_to_sexp(nodes, indent_level + indent_increment),
344                indent = indent_level
345            )
346        }
347        Node::Table {
348            alignments,
349            head,
350            rows,
351            source_range,
352        } => {
353            let cells = |row: &[RichText]| {
354                row.iter()
355                    .map(|cell| format!("\"{cell}\""))
356                    .collect::<Vec<_>>()
357                    .join(" ")
358            };
359            let inner_indent = indent_level + indent_increment;
360            let body = rows
361                .iter()
362                .map(|row| format!("{:inner_indent$}(row {})", "", cells(row)))
363                .collect::<Vec<_>>()
364                .join("\n");
365            format!(
366                "{:indent$}(table {:?} @{:?}\n{:inner_indent$}(head {})\n{})",
367                "",
368                alignments,
369                source_range,
370                "",
371                cells(head),
372                body,
373                indent = indent_level,
374            )
375        }
376    }
377}
378
379pub fn rich_text_to_sexp(rich_text: &RichText, indent_level: usize) -> String {
380    rich_text
381        .segments()
382        .iter()
383        .map(|segment| match &segment.style {
384            Some(style) => format!(
385                "{:indent$}({} \"{}\")",
386                "",
387                style,
388                segment,
389                indent = indent_level
390            ),
391            None => format!("{:indent$}\"{}\"", "", segment, indent = indent_level),
392        })
393        .collect::<Vec<_>>()
394        .join("\n")
395}