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