Skip to main content

markdown/
doc.rs

1//! The document model.
2//!
3//! A [`Doc`] is a **flat** list of [`Block`]s with an indent level, not a
4//! nested tree. That is Notion's model rather than CommonMark's, and it is the
5//! decision the rest of this crate hangs off: editing a flat list means Enter
6//! splits, Backspace merges, and Tab indents — all list operations. On a
7//! nested tree "the previous block" is a traversal and every edit is a
8//! restructure.
9//!
10//! The trade is that arbitrarily nested CommonMark does not survive a round
11//! trip: a quote inside a quote flattens to one. Notion has the same
12//! limitation. What is guaranteed is [`crate::serialize`]'s fixed point —
13//! parse, serialize, parse again, and the document is unchanged — so an
14//! edit/save cycle never drifts.
15
16use std::ops::Range;
17
18/// A markdown document: blocks in document order.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct Doc {
21    pub blocks: Vec<Block>,
22}
23
24impl Doc {
25    /// Make each run of ordered items consecutive.
26    ///
27    /// Markdown honours only the *first* number in a list — `1.` followed by `9.`
28    /// renders as 1, 2. Two source lists that used different delimiters (`1.` then
29    /// `9)`) are separate lists, but a flat document has no list identity to
30    /// preserve, so they serialize as one and the second item's number would move
31    /// on the next read. Deciding it here means the document already holds what the
32    /// next parse would produce.
33    pub(crate) fn renumber(&mut self) {
34        // The number owed to the next ordered item at each indent level. A run
35        // survives blocks nested under it and ends at anything else.
36        let mut expected: Vec<Option<u64>> = Vec::new();
37        let mut runs = Runs::default();
38        let mut above: Option<Run> = None;
39        for block in &mut self.blocks {
40            let indent = block.indent as usize;
41            let run = runs.next(block.quote, block.indent);
42            // A list on the other side of a quote's `>` is another list.
43            if run.is_none_or(|run| run.first) {
44                let edge = [above, run]
45                    .into_iter()
46                    .flatten()
47                    .map(|run| run.level as usize)
48                    .min();
49                if let Some(edge) = edge {
50                    expected.truncate(edge);
51                }
52            }
53            above = run;
54            expected.truncate(indent + 1);
55            expected.resize(indent + 1, None);
56
57            if let BlockKind::Ordered { number, .. } = &mut block.kind {
58                if let Some(next) = expected[indent] {
59                    *number = next;
60                }
61                expected[indent] = Some(number.saturating_add(1));
62            } else {
63                expected[indent] = None;
64            }
65        }
66    }
67}
68
69/// One block, and how deeply it is nested.
70///
71/// `indent` obeys one invariant, established by the parser and relied on by
72/// the serializer: the first block is at 0, and no block is more than one
73/// level deeper than the block before it. A document that satisfies it always
74/// serializes to markdown that parses back to the same indents.
75#[derive(Debug, Clone, PartialEq, Eq, Hash)]
76pub struct Block {
77    pub kind: BlockKind,
78    pub indent: u8,
79    /// The blockquote the block sits in, or `None` outside one. Adjacent
80    /// quoted blocks form one blockquote — see [`quote_runs`].
81    pub quote: Option<Quoted>,
82}
83
84/// A block's place in a blockquote.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
86pub struct Quoted {
87    /// The GFM alert the blockquote opens with — see [`QuoteKind`].
88    pub alert: Option<QuoteKind>,
89}
90
91/// Where a quoted block falls in its blockquote.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub(crate) struct Run {
94    /// The indent the `>` is written at: the indent of the run's first block.
95    pub level: u8,
96    pub first: bool,
97}
98
99/// Each block's place in a blockquote, or `None` for an unquoted block.
100///
101/// A quoted block continues the run before it when it carries the same
102/// [`Quoted`] and sits no shallower than the run's level. Anything else starts
103/// a new run at its own indent.
104pub(crate) fn quote_runs(
105    blocks: impl IntoIterator<Item = (Option<Quoted>, u8)>,
106) -> Vec<Option<Run>> {
107    let mut runs = Runs::default();
108    blocks
109        .into_iter()
110        .map(|(quote, indent)| runs.next(quote, indent))
111        .collect()
112}
113
114/// [`quote_runs`] one block at a time.
115#[derive(Default)]
116pub(crate) struct Runs {
117    open: Option<(Quoted, u8)>,
118}
119
120impl Runs {
121    pub fn next(&mut self, quote: Option<Quoted>, indent: u8) -> Option<Run> {
122        let Some(quote) = quote else {
123            self.open = None;
124            return None;
125        };
126        match self.open {
127            Some((current, level)) if current == quote && indent >= level => Some(Run {
128                level,
129                first: false,
130            }),
131            _ => {
132                self.open = Some((quote, indent));
133                Some(Run {
134                    level: indent,
135                    first: true,
136                })
137            }
138        }
139    }
140}
141
142impl From<BlockKind> for Block {
143    fn from(kind: BlockKind) -> Self {
144        Self::new(kind)
145    }
146}
147
148impl Block {
149    pub fn new(kind: BlockKind) -> Self {
150        Self::at(kind, 0)
151    }
152
153    pub fn at(kind: BlockKind, indent: u8) -> Self {
154        Self {
155            kind,
156            indent,
157            quote: None,
158        }
159    }
160
161    pub fn quoted(self, quote: Quoted) -> Self {
162        Self {
163            quote: Some(quote),
164            ..self
165        }
166    }
167
168    /// One of the block's editable texts. `None` when the block has no such
169    /// part — every block is atomic to some [`Part`], and bookmarks and rules
170    /// are atomic to all of them.
171    pub fn text_at(&self, part: Part) -> Option<&Text> {
172        match (&self.kind, part) {
173            (
174                BlockKind::Paragraph(text)
175                | BlockKind::Heading { text, .. }
176                | BlockKind::Bullet(text)
177                | BlockKind::Ordered { text, .. }
178                | BlockKind::Task { text, .. },
179                Part::Body,
180            ) => Some(text),
181            (BlockKind::Code { code, .. }, Part::Code) => Some(code),
182            (BlockKind::Image { alt, .. }, Part::Caption) => Some(alt),
183            (BlockKind::Table { header, .. }, Part::Cell { row: 0, column }) => header.get(column),
184            (BlockKind::Table { rows, .. }, Part::Cell { row, column }) => {
185                rows.get(row - 1)?.get(column)
186            }
187            _ => None,
188        }
189    }
190
191    pub fn text_at_mut(&mut self, part: Part) -> Option<&mut Text> {
192        match (&mut self.kind, part) {
193            (
194                BlockKind::Paragraph(text)
195                | BlockKind::Heading { text, .. }
196                | BlockKind::Bullet(text)
197                | BlockKind::Ordered { text, .. }
198                | BlockKind::Task { text, .. },
199                Part::Body,
200            ) => Some(text),
201            (BlockKind::Code { code, .. }, Part::Code) => Some(code),
202            (BlockKind::Image { alt, .. }, Part::Caption) => Some(alt),
203            (BlockKind::Table { header, .. }, Part::Cell { row: 0, column }) => {
204                header.get_mut(column)
205            }
206            (BlockKind::Table { rows, .. }, Part::Cell { row, column }) => {
207                rows.get_mut(row - 1)?.get_mut(column)
208            }
209            _ => None,
210        }
211    }
212
213    /// Every part a caret can sit in, in document order.
214    pub fn parts(&self) -> Vec<Part> {
215        match &self.kind {
216            BlockKind::Paragraph(_)
217            | BlockKind::Heading { .. }
218            | BlockKind::Bullet(_)
219            | BlockKind::Ordered { .. }
220            | BlockKind::Task { .. } => vec![Part::Body],
221            BlockKind::Code { .. } => vec![Part::Code],
222            BlockKind::Image { .. } => vec![Part::Caption],
223            BlockKind::Table { header, rows, .. } => {
224                let mut parts = Vec::new();
225                if !header.is_empty() {
226                    parts.extend((0..header.len()).map(|column| Part::Cell { row: 0, column }));
227                }
228                for (ix, row) in rows.iter().enumerate() {
229                    parts.extend((0..row.len()).map(|column| Part::Cell {
230                        row: ix + 1,
231                        column,
232                    }));
233                }
234                parts
235            }
236            BlockKind::Bookmark { .. } | BlockKind::Rule => Vec::new(),
237        }
238    }
239
240    /// Whether the block is a blank paragraph: the empty line an editor leaves
241    /// behind, which markdown has no way to write down. The empty body of an
242    /// alert is not one: `> [!TIP]` writes down and reads back.
243    pub(crate) fn blank(&self) -> bool {
244        matches!(&self.kind, BlockKind::Paragraph(text) if text.is_empty() && text.marks.is_empty())
245            && !self.quote.is_some_and(|quote| quote.alert.is_some())
246    }
247
248    /// Whether what the block paints past its parts holds no caret — a picture,
249    /// a card, a line. What a selection has to wash for itself, since there is
250    /// no text under it to carry the highlight.
251    pub fn opaque(&self) -> bool {
252        matches!(
253            self.kind,
254            BlockKind::Image { .. } | BlockKind::Bookmark { .. } | BlockKind::Rule
255        )
256    }
257}
258
259/// Which of a block's texts a caret sits in.
260///
261/// A block has one kind of part and never a mix — prose blocks have a body, a
262/// code block has its code, a table has cells — so this is a coordinate rather
263/// than a path, and the model stays flat. The ordering is document order, which
264/// is what makes a [`crate::Cursor`] comparable and therefore what makes a
265/// selection a range.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
267pub enum Part {
268    #[default]
269    Body,
270    Code,
271    /// An image's caption, which is also its alt text.
272    Caption,
273    /// Row 0 is the header row; row `n` is `rows[n - 1]`.
274    Cell {
275        row: usize,
276        column: usize,
277    },
278}
279
280/// The block vocabulary. Closed by design — a consumer that needs a block of
281/// its own is a reason to widen this enum rather than to grow an extension
282/// system.
283#[derive(Debug, Clone, PartialEq, Eq, Hash)]
284pub enum BlockKind {
285    Paragraph(Text),
286    Heading {
287        /// 1–6.
288        level: u8,
289        text: Text,
290    },
291    Bullet(Text),
292    Ordered {
293        /// The rendered number. Stored rather than derived so a list starting
294        /// at 3 survives the round trip.
295        number: u64,
296        text: Text,
297    },
298    Task {
299        checked: bool,
300        text: Text,
301    },
302    /// The code carries a [`Text`] like every other editable region, so one
303    /// accessor and one edit path cover the whole document. Its marks are
304    /// unreachable rather than forbidden: nothing that writes here creates one.
305    Code {
306        language: Option<String>,
307        code: Text,
308        /// The height a painted fence stands at, in whole pixels, from a drag
309        /// off its handle in `bezel-editor`. `None` is the block's own height.
310        /// Spelled as the info string's second word: ` ```mermaid 360 `.
311        height: Option<u32>,
312    },
313    /// The caption is the alt text — markdown has one slot, and a reader that
314    /// cannot see the picture reads the same words. Like [`BlockKind::Code`]'s,
315    /// its marks are unreachable rather than forbidden.
316    Image {
317        url: String,
318        alt: Text,
319        /// A drag off the handle in `bezel-editor`, in whole pixels — `None` is
320        /// the natural width. `u32` rather than a float: this derives `Eq`, and
321        /// a `f32` cannot because of NaN. Spelled `![alt|480](url)`, which
322        /// leaves the title slot to say what a title says.
323        width: Option<u32>,
324    },
325    /// A link with a block to itself, painted richly. Atomic on purpose:
326    /// everything it shows past the URL comes from [`crate::app::preview`], so there
327    /// is nothing here for a caret to edit.
328    ///
329    /// [`Form`] picks a card or a card with its picture across the width;
330    /// [`Form::Chip`] paints as the card. A chip is a [`Mark::Mention`], on a
331    /// line of its own or not.
332    Bookmark {
333        url: String,
334        form: Form,
335    },
336    Table {
337        align: Vec<Align>,
338        header: Vec<Text>,
339        rows: Vec<Vec<Text>>,
340    },
341    Rule,
342}
343
344/// A GFM alert's kind — the `[!NOTE]` marker a blockquote opens with.
345///
346/// Only recognised when the marker is alone on the quote's first line and
347/// names one of these five; anything else stays the text it was written as.
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
349pub enum QuoteKind {
350    Note,
351    Tip,
352    Important,
353    Warning,
354    Caution,
355}
356
357impl QuoteKind {
358    /// The marker line, bracket to bracket.
359    pub fn marker(self) -> &'static str {
360        match self {
361            Self::Note => "[!NOTE]",
362            Self::Tip => "[!TIP]",
363            Self::Important => "[!IMPORTANT]",
364            Self::Warning => "[!WARNING]",
365            Self::Caution => "[!CAUTION]",
366        }
367    }
368
369    /// What the alert calls itself where it is painted.
370    pub fn label(self) -> &'static str {
371        match self {
372            Self::Note => "Note",
373            Self::Tip => "Tip",
374            Self::Important => "Important",
375            Self::Warning => "Warning",
376            Self::Caution => "Caution",
377        }
378    }
379}
380
381/// GFM column alignment.
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
383pub enum Align {
384    #[default]
385    Left,
386    Center,
387    Right,
388}
389
390/// Inline content: a string, plus marks over byte ranges of it.
391///
392/// Marks are a separate list rather than flags on a run because an editor has
393/// to *map* them through insertions and deletions, and because run flags lose
394/// nesting order — under flags `**_x_**` and `_**x**_` are the same value.
395/// Here they differ by the order of the two spans, and both survive a round
396/// trip.
397///
398/// A newline in `text` is a line break within the block (markdown's soft or
399/// hard break, which this model does not distinguish — neither does Notion).
400/// Whether it paints as a break or a space is a rendering decision.
401#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
402pub struct Text {
403    pub text: String,
404    /// Outermost first. Ranges may overlap and may be identical.
405    pub marks: Vec<MarkSpan>,
406}
407
408impl From<&str> for Text {
409    fn from(text: &str) -> Self {
410        Self::plain(text)
411    }
412}
413
414impl From<String> for Text {
415    fn from(text: String) -> Self {
416        Self::plain(text)
417    }
418}
419
420impl Text {
421    /// Unmarked text.
422    pub fn plain(text: impl Into<String>) -> Self {
423        Self {
424            text: text.into(),
425            marks: Vec::new(),
426        }
427    }
428
429    /// A URL that links to itself — what a pasted link is, and what a bookmark
430    /// hands back when it turns into prose.
431    pub fn link(url: &str) -> Self {
432        Self {
433            text: url.to_string(),
434            marks: vec![MarkSpan {
435                range: 0..url.len(),
436                mark: Mark::Link(url.to_string()),
437            }],
438        }
439    }
440
441    pub fn is_empty(&self) -> bool {
442        self.text.is_empty()
443    }
444
445    /// The mention `offset` falls strictly inside. A caret has no place there:
446    /// the mention shows its preview, not its text.
447    pub(crate) fn mention_around(&self, offset: usize) -> Option<Range<usize>> {
448        self.marks
449            .iter()
450            .find(|span| {
451                matches!(span.mark, Mark::Mention { .. })
452                    && span.range.start < offset
453                    && offset < span.range.end
454            })
455            .map(|span| span.range.clone())
456    }
457
458    /// Whether no other mark overlaps the one at `ix`.
459    ///
460    /// A mark written whole — a code span, a mention — leaves no room inside
461    /// itself for another mark's boundary, so this is what decides whether it
462    /// can be spelled its own way at all.
463    pub(crate) fn alone(&self, ix: usize) -> bool {
464        let span = &self.marks[ix].range;
465        self.marks.iter().enumerate().all(|(other, mark)| {
466            other == ix || mark.range.end <= span.start || mark.range.start >= span.end
467        })
468    }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Hash)]
472pub struct MarkSpan {
473    pub range: Range<usize>,
474    pub mark: Mark,
475}
476
477/// How a [`Mark::Mention`] was written down, which is also how it paints.
478///
479/// `Auto` is the shorthand `<https://x>`: a chip in a sentence, a card on a
480/// line of its own. It is a variant rather than a resolved form because the
481/// spelling is what has to survive the round trip — resolve it at parse and
482/// every `<url>` grows brackets the first time the file is saved.
483///
484/// The other two are CommonMark's title slot, `[url](url "chip")`, which is
485/// core, ignored by every other renderer, and the only place left to say what
486/// the shorthand cannot: a chip alone on a line, and the bigger card.
487/// [`Form::Chip`] stays inline wherever it is.
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
489pub enum Form {
490    Auto,
491    Chip,
492    /// The bigger card, at a height in whole pixels from a drag off its
493    /// handle in `bezel-editor`, or its own height for `None`. Spelled
494    /// `"embed 360"`.
495    Embed(Option<u32>),
496}
497
498impl Form {
499    /// The title that spells this form, and `None` for the shorthand.
500    pub(crate) fn title(self) -> Option<String> {
501        match self {
502            Self::Auto => None,
503            Self::Chip => Some("chip".to_owned()),
504            Self::Embed(None) => Some("embed".to_owned()),
505            Self::Embed(Some(height)) => Some(format!("embed {height}")),
506        }
507    }
508
509    pub(crate) fn from_title(title: &str) -> Option<Self> {
510        let (name, height) = crate::parse::split_size(title);
511        match (name, height) {
512            ("chip", None) => Some(Self::Chip),
513            ("embed", height) => Some(Self::Embed(height)),
514            _ => None,
515        }
516    }
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Hash)]
520pub enum Mark {
521    Bold,
522    Italic,
523    Strike,
524    Code,
525    Link(String),
526    /// A link painted richly rather than as underlined text — a chip inline, a
527    /// [`BlockKind::Bookmark`] with a block to itself.
528    ///
529    /// The chip shows a favicon and the [`crate::app::preview`] title in place of
530    /// its text, and opens the card on hover. Its text is atomic: a caret sits
531    /// at either end, never inside, and typing at its end falls outside it.
532    Mention {
533        url: String,
534        form: Form,
535    },
536    /// A mark the app spells itself — underline, a highlight, a colour. The
537    /// name is [`crate::Marks`]'s, and the delimiter that writes it comes from
538    /// the same registry.
539    Custom(String),
540    /// An image among text. [`BlockKind::Image`] is the shape an editor offers;
541    /// this is what keeps `see ![x](u) here` from silently becoming a link when
542    /// the document is saved. No width: one among text has no box of its own to
543    /// resize, and the `|480` that would say so stays the ordinary text it is.
544    Image(String),
545}