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 what the block paints past its parts holds no caret — a picture,
241 /// a card, a line. What a selection has to wash for itself, since there is
242 /// no text under it to carry the highlight.
243 pub fn opaque(&self) -> bool {
244 matches!(
245 self.kind,
246 BlockKind::Image { .. } | BlockKind::Bookmark { .. } | BlockKind::Rule
247 )
248 }
249}
250
251/// Which of a block's texts a caret sits in.
252///
253/// A block has one kind of part and never a mix — prose blocks have a body, a
254/// code block has its code, a table has cells — so this is a coordinate rather
255/// than a path, and the model stays flat. The ordering is document order, which
256/// is what makes a [`crate::Cursor`] comparable and therefore what makes a
257/// selection a range.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
259pub enum Part {
260 #[default]
261 Body,
262 Code,
263 /// An image's caption, which is also its alt text.
264 Caption,
265 /// Row 0 is the header row; row `n` is `rows[n - 1]`.
266 Cell {
267 row: usize,
268 column: usize,
269 },
270}
271
272/// The block vocabulary. Closed by design — a consumer that needs a block of
273/// its own is a reason to widen this enum rather than to grow an extension
274/// system.
275#[derive(Debug, Clone, PartialEq, Eq, Hash)]
276pub enum BlockKind {
277 Paragraph(Text),
278 Heading {
279 /// 1–6.
280 level: u8,
281 text: Text,
282 },
283 Bullet(Text),
284 Ordered {
285 /// The rendered number. Stored rather than derived so a list starting
286 /// at 3 survives the round trip.
287 number: u64,
288 text: Text,
289 },
290 Task {
291 checked: bool,
292 text: Text,
293 },
294 /// The code carries a [`Text`] like every other editable region, so one
295 /// accessor and one edit path cover the whole document. Its marks are
296 /// unreachable rather than forbidden: nothing that writes here creates one.
297 Code {
298 language: Option<String>,
299 code: Text,
300 /// The height a painted fence stands at, in whole pixels, from a drag
301 /// off its handle in `bezel-editor`. `None` is the block's own height.
302 /// Spelled as the info string's second word: ` ```mermaid 360 `.
303 height: Option<u32>,
304 },
305 /// The caption is the alt text — markdown has one slot, and a reader that
306 /// cannot see the picture reads the same words. Like [`BlockKind::Code`]'s,
307 /// its marks are unreachable rather than forbidden.
308 Image {
309 url: String,
310 alt: Text,
311 /// A drag off the handle in `bezel-editor`, in whole pixels — `None` is
312 /// the natural width. `u32` rather than a float: this derives `Eq`, and
313 /// a `f32` cannot because of NaN. Spelled ``, which
314 /// leaves the title slot to say what a title says.
315 width: Option<u32>,
316 },
317 /// A link with a block to itself, painted richly. Atomic on purpose:
318 /// everything it shows past the URL comes from [`crate::preview`], so there
319 /// is nothing here for a caret to edit.
320 ///
321 /// [`Form`] picks a card or a card with its picture across the width;
322 /// [`Form::Chip`] paints as the card. A chip is a [`Mark::Mention`], on a
323 /// line of its own or not.
324 Bookmark {
325 url: String,
326 form: Form,
327 },
328 Table {
329 align: Vec<Align>,
330 header: Vec<Text>,
331 rows: Vec<Vec<Text>>,
332 },
333 Rule,
334}
335
336/// A GFM alert's kind — the `[!NOTE]` marker a blockquote opens with.
337///
338/// Only recognised when the marker is alone on the quote's first line and
339/// names one of these five; anything else stays the text it was written as.
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
341pub enum QuoteKind {
342 Note,
343 Tip,
344 Important,
345 Warning,
346 Caution,
347}
348
349impl QuoteKind {
350 /// The marker line, bracket to bracket.
351 pub fn marker(self) -> &'static str {
352 match self {
353 Self::Note => "[!NOTE]",
354 Self::Tip => "[!TIP]",
355 Self::Important => "[!IMPORTANT]",
356 Self::Warning => "[!WARNING]",
357 Self::Caution => "[!CAUTION]",
358 }
359 }
360
361 /// What the alert calls itself where it is painted.
362 pub fn label(self) -> &'static str {
363 match self {
364 Self::Note => "Note",
365 Self::Tip => "Tip",
366 Self::Important => "Important",
367 Self::Warning => "Warning",
368 Self::Caution => "Caution",
369 }
370 }
371}
372
373/// GFM column alignment.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
375pub enum Align {
376 #[default]
377 Left,
378 Center,
379 Right,
380}
381
382/// Inline content: a string, plus marks over byte ranges of it.
383///
384/// Marks are a separate list rather than flags on a run because an editor has
385/// to *map* them through insertions and deletions, and because run flags lose
386/// nesting order — under flags `**_x_**` and `_**x**_` are the same value.
387/// Here they differ by the order of the two spans, and both survive a round
388/// trip.
389///
390/// A newline in `text` is a line break within the block (markdown's soft or
391/// hard break, which this model does not distinguish — neither does Notion).
392/// Whether it paints as a break or a space is a rendering decision.
393#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
394pub struct Text {
395 pub text: String,
396 /// Outermost first. Ranges may overlap and may be identical.
397 pub marks: Vec<MarkSpan>,
398}
399
400impl From<&str> for Text {
401 fn from(text: &str) -> Self {
402 Self::plain(text)
403 }
404}
405
406impl From<String> for Text {
407 fn from(text: String) -> Self {
408 Self::plain(text)
409 }
410}
411
412impl Text {
413 /// Unmarked text.
414 pub fn plain(text: impl Into<String>) -> Self {
415 Self {
416 text: text.into(),
417 marks: Vec::new(),
418 }
419 }
420
421 /// A URL that links to itself — what a pasted link is, and what a bookmark
422 /// hands back when it turns into prose.
423 pub fn link(url: &str) -> Self {
424 Self {
425 text: url.to_string(),
426 marks: vec![MarkSpan {
427 range: 0..url.len(),
428 mark: Mark::Link(url.to_string()),
429 }],
430 }
431 }
432
433 pub fn is_empty(&self) -> bool {
434 self.text.is_empty()
435 }
436
437 /// The mention `offset` falls strictly inside. A caret has no place there:
438 /// the mention shows its preview, not its text.
439 pub(crate) fn mention_around(&self, offset: usize) -> Option<Range<usize>> {
440 self.marks
441 .iter()
442 .find(|span| {
443 matches!(span.mark, Mark::Mention { .. })
444 && span.range.start < offset
445 && offset < span.range.end
446 })
447 .map(|span| span.range.clone())
448 }
449
450 /// Whether no other mark overlaps the one at `ix`.
451 ///
452 /// A mark written whole — a code span, a mention — leaves no room inside
453 /// itself for another mark's boundary, so this is what decides whether it
454 /// can be spelled its own way at all.
455 pub(crate) fn alone(&self, ix: usize) -> bool {
456 let span = &self.marks[ix].range;
457 self.marks.iter().enumerate().all(|(other, mark)| {
458 other == ix || mark.range.end <= span.start || mark.range.start >= span.end
459 })
460 }
461}
462
463#[derive(Debug, Clone, PartialEq, Eq, Hash)]
464pub struct MarkSpan {
465 pub range: Range<usize>,
466 pub mark: Mark,
467}
468
469/// How a [`Mark::Mention`] was written down, which is also how it paints.
470///
471/// `Auto` is the shorthand `<https://x>`: a chip in a sentence, a card on a
472/// line of its own. It is a variant rather than a resolved form because the
473/// spelling is what has to survive the round trip — resolve it at parse and
474/// every `<url>` grows brackets the first time the file is saved.
475///
476/// The other two are CommonMark's title slot, `[url](url "chip")`, which is
477/// core, ignored by every other renderer, and the only place left to say what
478/// the shorthand cannot: a chip alone on a line, and the bigger card.
479/// [`Form::Chip`] stays inline wherever it is.
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
481pub enum Form {
482 Auto,
483 Chip,
484 /// The bigger card, at a height in whole pixels from a drag off its
485 /// handle in `bezel-editor`, or its own height for `None`. Spelled
486 /// `"embed 360"`.
487 Embed(Option<u32>),
488}
489
490impl Form {
491 /// The title that spells this form, and `None` for the shorthand.
492 pub(crate) fn title(self) -> Option<String> {
493 match self {
494 Self::Auto => None,
495 Self::Chip => Some("chip".to_owned()),
496 Self::Embed(None) => Some("embed".to_owned()),
497 Self::Embed(Some(height)) => Some(format!("embed {height}")),
498 }
499 }
500
501 pub(crate) fn from_title(title: &str) -> Option<Self> {
502 let (name, height) = crate::parse::split_size(title);
503 match (name, height) {
504 ("chip", None) => Some(Self::Chip),
505 ("embed", height) => Some(Self::Embed(height)),
506 _ => None,
507 }
508 }
509}
510
511#[derive(Debug, Clone, PartialEq, Eq, Hash)]
512pub enum Mark {
513 Bold,
514 Italic,
515 Strike,
516 Code,
517 Link(String),
518 /// A link painted richly rather than as underlined text — a chip inline, a
519 /// [`BlockKind::Bookmark`] with a block to itself.
520 ///
521 /// The chip shows a favicon and the [`crate::preview`] title in place of
522 /// its text, and opens the card on hover. Its text is atomic: a caret sits
523 /// at either end, never inside, and typing at its end falls outside it.
524 Mention {
525 url: String,
526 form: Form,
527 },
528 /// A mark the app spells itself — underline, a highlight, a colour. The
529 /// name is [`crate::Marks`]'s, and the delimiter that writes it comes from
530 /// the same registry.
531 Custom(String),
532 /// An image among text. [`BlockKind::Image`] is the shape an editor offers;
533 /// this is what keeps `see  here` from silently becoming a link when
534 /// the document is saved. No width: one among text has no box of its own to
535 /// resize, and the `|480` that would say so stays the ordinary text it is.
536 Image(String),
537}