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 list inside a quote inside a list flattens. 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 for block in &mut self.blocks {
38 let indent = block.indent as usize;
39 expected.truncate(indent + 1);
40 expected.resize(indent + 1, None);
41
42 if let BlockKind::Ordered { number, .. } = &mut block.kind {
43 if let Some(next) = expected[indent] {
44 *number = next;
45 }
46 expected[indent] = Some(number.saturating_add(1));
47 } else {
48 expected[indent] = None;
49 }
50 }
51 }
52}
53
54/// One block, and how deeply it is nested.
55///
56/// `indent` obeys one invariant, established by the parser and relied on by
57/// the serializer: the first block is at 0, and no block is more than one
58/// level deeper than the block before it. A document that satisfies it always
59/// serializes to markdown that parses back to the same indents.
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub struct Block {
62 pub kind: BlockKind,
63 pub indent: u8,
64}
65
66impl From<BlockKind> for Block {
67 fn from(kind: BlockKind) -> Self {
68 Self::new(kind)
69 }
70}
71
72impl Block {
73 pub fn new(kind: BlockKind) -> Self {
74 Self { kind, indent: 0 }
75 }
76
77 pub fn at(kind: BlockKind, indent: u8) -> Self {
78 Self { kind, indent }
79 }
80
81 /// One of the block's editable texts. `None` when the block has no such
82 /// part — every block is atomic to some [`Part`], and bookmarks and rules
83 /// are atomic to all of them.
84 pub fn text_at(&self, part: Part) -> Option<&Text> {
85 match (&self.kind, part) {
86 (
87 BlockKind::Paragraph(text)
88 | BlockKind::Heading { text, .. }
89 | BlockKind::Bullet(text)
90 | BlockKind::Ordered { text, .. }
91 | BlockKind::Task { text, .. }
92 | BlockKind::Quote { text, .. },
93 Part::Body,
94 ) => Some(text),
95 (BlockKind::Code { code, .. }, Part::Code) => Some(code),
96 (BlockKind::Image { alt, .. }, Part::Caption) => Some(alt),
97 (BlockKind::Table { header, .. }, Part::Cell { row: 0, column }) => header.get(column),
98 (BlockKind::Table { rows, .. }, Part::Cell { row, column }) => {
99 rows.get(row - 1)?.get(column)
100 }
101 _ => None,
102 }
103 }
104
105 pub fn text_at_mut(&mut self, part: Part) -> Option<&mut Text> {
106 match (&mut self.kind, part) {
107 (
108 BlockKind::Paragraph(text)
109 | BlockKind::Heading { text, .. }
110 | BlockKind::Bullet(text)
111 | BlockKind::Ordered { text, .. }
112 | BlockKind::Task { text, .. }
113 | BlockKind::Quote { text, .. },
114 Part::Body,
115 ) => Some(text),
116 (BlockKind::Code { code, .. }, Part::Code) => Some(code),
117 (BlockKind::Image { alt, .. }, Part::Caption) => Some(alt),
118 (BlockKind::Table { header, .. }, Part::Cell { row: 0, column }) => {
119 header.get_mut(column)
120 }
121 (BlockKind::Table { rows, .. }, Part::Cell { row, column }) => {
122 rows.get_mut(row - 1)?.get_mut(column)
123 }
124 _ => None,
125 }
126 }
127
128 /// Every part a caret can sit in, in document order.
129 pub fn parts(&self) -> Vec<Part> {
130 match &self.kind {
131 BlockKind::Paragraph(_)
132 | BlockKind::Heading { .. }
133 | BlockKind::Bullet(_)
134 | BlockKind::Ordered { .. }
135 | BlockKind::Task { .. }
136 | BlockKind::Quote { .. } => vec![Part::Body],
137 BlockKind::Code { .. } => vec![Part::Code],
138 BlockKind::Image { .. } => vec![Part::Caption],
139 BlockKind::Table { header, rows, .. } => {
140 let mut parts = Vec::new();
141 if !header.is_empty() {
142 parts.extend((0..header.len()).map(|column| Part::Cell { row: 0, column }));
143 }
144 for (ix, row) in rows.iter().enumerate() {
145 parts.extend((0..row.len()).map(|column| Part::Cell {
146 row: ix + 1,
147 column,
148 }));
149 }
150 parts
151 }
152 BlockKind::Bookmark { .. } | BlockKind::Rule => Vec::new(),
153 }
154 }
155
156 /// Whether what the block paints past its parts holds no caret — a picture,
157 /// a card, a line. What a selection has to wash for itself, since there is
158 /// no text under it to carry the highlight.
159 pub fn opaque(&self) -> bool {
160 matches!(
161 self.kind,
162 BlockKind::Image { .. } | BlockKind::Bookmark { .. } | BlockKind::Rule
163 )
164 }
165}
166
167/// Which of a block's texts a caret sits in.
168///
169/// A block has one kind of part and never a mix — prose blocks have a body, a
170/// code block has its code, a table has cells — so this is a coordinate rather
171/// than a path, and the model stays flat. The ordering is document order, which
172/// is what makes a [`crate::Cursor`] comparable and therefore what makes a
173/// selection a range.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
175pub enum Part {
176 #[default]
177 Body,
178 Code,
179 /// An image's caption, which is also its alt text.
180 Caption,
181 /// Row 0 is the header row; row `n` is `rows[n - 1]`.
182 Cell {
183 row: usize,
184 column: usize,
185 },
186}
187
188/// The block vocabulary. Closed by design — a consumer that needs a block of
189/// its own is a reason to widen this enum rather than to grow an extension
190/// system.
191#[derive(Debug, Clone, PartialEq, Eq, Hash)]
192pub enum BlockKind {
193 Paragraph(Text),
194 Heading {
195 /// 1–6.
196 level: u8,
197 text: Text,
198 },
199 Bullet(Text),
200 Ordered {
201 /// The rendered number. Stored rather than derived so a list starting
202 /// at 3 survives the round trip.
203 number: u64,
204 text: Text,
205 },
206 Task {
207 checked: bool,
208 text: Text,
209 },
210 /// A blockquote. `kind` is the GFM alert it opens with — see [`QuoteKind`].
211 Quote {
212 kind: Option<QuoteKind>,
213 text: Text,
214 },
215 /// The code carries a [`Text`] like every other editable region, so one
216 /// accessor and one edit path cover the whole document. Its marks are
217 /// unreachable rather than forbidden: nothing that writes here creates one.
218 Code {
219 language: Option<String>,
220 code: Text,
221 },
222 /// The caption is the alt text — markdown has one slot, and a reader that
223 /// cannot see the picture reads the same words. Like [`BlockKind::Code`]'s,
224 /// its marks are unreachable rather than forbidden.
225 Image {
226 url: String,
227 alt: Text,
228 /// A drag off the handle in `bezel-editor`, in whole pixels — `None` is
229 /// the natural width. `u32` rather than a float: this derives `Eq`, and
230 /// a `f32` cannot because of NaN. Spelled ``, which
231 /// leaves the title slot to say what a title says.
232 width: Option<u32>,
233 },
234 /// A link with a block to itself, painted richly. Atomic on purpose:
235 /// everything it shows past the URL comes from [`crate::preview`], so there
236 /// is nothing here for a caret to edit.
237 ///
238 /// [`Form`] picks a card or a card with its picture across the width;
239 /// [`Form::Chip`] paints as the card. A chip is a [`Mark::Mention`], on a
240 /// line of its own or not.
241 Bookmark {
242 url: String,
243 form: Form,
244 },
245 Table {
246 align: Vec<Align>,
247 header: Vec<Text>,
248 rows: Vec<Vec<Text>>,
249 },
250 Rule,
251}
252
253/// A GFM alert's kind — the `[!NOTE]` marker a blockquote opens with.
254///
255/// Only recognised when the marker is alone on the quote's first line and
256/// names one of these five; anything else stays the text it was written as.
257/// A blockquote holding two paragraphs becomes two [`BlockKind::Quote`] blocks
258/// and each carries the kind, so writing the document back gives two alerts.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
260pub enum QuoteKind {
261 Note,
262 Tip,
263 Important,
264 Warning,
265 Caution,
266}
267
268impl QuoteKind {
269 /// The marker line, bracket to bracket.
270 pub fn marker(self) -> &'static str {
271 match self {
272 Self::Note => "[!NOTE]",
273 Self::Tip => "[!TIP]",
274 Self::Important => "[!IMPORTANT]",
275 Self::Warning => "[!WARNING]",
276 Self::Caution => "[!CAUTION]",
277 }
278 }
279
280 /// What the alert calls itself where it is painted.
281 pub fn label(self) -> &'static str {
282 match self {
283 Self::Note => "Note",
284 Self::Tip => "Tip",
285 Self::Important => "Important",
286 Self::Warning => "Warning",
287 Self::Caution => "Caution",
288 }
289 }
290}
291
292/// GFM column alignment.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
294pub enum Align {
295 #[default]
296 Left,
297 Center,
298 Right,
299}
300
301/// Inline content: a string, plus marks over byte ranges of it.
302///
303/// Marks are a separate list rather than flags on a run because an editor has
304/// to *map* them through insertions and deletions, and because run flags lose
305/// nesting order — under flags `**_x_**` and `_**x**_` are the same value.
306/// Here they differ by the order of the two spans, and both survive a round
307/// trip.
308///
309/// A newline in `text` is a line break within the block (markdown's soft or
310/// hard break, which this model does not distinguish — neither does Notion).
311/// Whether it paints as a break or a space is a rendering decision.
312#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
313pub struct Text {
314 pub text: String,
315 /// Outermost first. Ranges may overlap and may be identical.
316 pub marks: Vec<MarkSpan>,
317}
318
319impl From<&str> for Text {
320 fn from(text: &str) -> Self {
321 Self::plain(text)
322 }
323}
324
325impl From<String> for Text {
326 fn from(text: String) -> Self {
327 Self::plain(text)
328 }
329}
330
331impl Text {
332 /// Unmarked text.
333 pub fn plain(text: impl Into<String>) -> Self {
334 Self {
335 text: text.into(),
336 marks: Vec::new(),
337 }
338 }
339
340 /// A URL that links to itself — what a pasted link is, and what a bookmark
341 /// hands back when it turns into prose.
342 pub fn link(url: &str) -> Self {
343 Self {
344 text: url.to_string(),
345 marks: vec![MarkSpan {
346 range: 0..url.len(),
347 mark: Mark::Link(url.to_string()),
348 }],
349 }
350 }
351
352 pub fn is_empty(&self) -> bool {
353 self.text.is_empty()
354 }
355
356 /// The mention `offset` falls strictly inside. A caret has no place there:
357 /// the mention shows its preview, not its text.
358 pub(crate) fn mention_around(&self, offset: usize) -> Option<Range<usize>> {
359 self.marks
360 .iter()
361 .find(|span| {
362 matches!(span.mark, Mark::Mention { .. })
363 && span.range.start < offset
364 && offset < span.range.end
365 })
366 .map(|span| span.range.clone())
367 }
368
369 /// Whether no other mark overlaps the one at `ix`.
370 ///
371 /// A mark written whole — a code span, a mention — leaves no room inside
372 /// itself for another mark's boundary, so this is what decides whether it
373 /// can be spelled its own way at all.
374 pub(crate) fn alone(&self, ix: usize) -> bool {
375 let span = &self.marks[ix].range;
376 self.marks.iter().enumerate().all(|(other, mark)| {
377 other == ix || mark.range.end <= span.start || mark.range.start >= span.end
378 })
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Hash)]
383pub struct MarkSpan {
384 pub range: Range<usize>,
385 pub mark: Mark,
386}
387
388/// How a [`Mark::Mention`] was written down, which is also how it paints.
389///
390/// `Auto` is the shorthand `<https://x>`: a chip in a sentence, a card on a
391/// line of its own. It is a variant rather than a resolved form because the
392/// spelling is what has to survive the round trip — resolve it at parse and
393/// every `<url>` grows brackets the first time the file is saved.
394///
395/// The other two are CommonMark's title slot, `[url](url "chip")`, which is
396/// core, ignored by every other renderer, and the only place left to say what
397/// the shorthand cannot: a chip alone on a line, and the bigger card.
398/// [`Form::Chip`] stays inline wherever it is.
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
400pub enum Form {
401 Auto,
402 Chip,
403 Embed,
404}
405
406impl Form {
407 /// The title that spells this form, and `None` for the shorthand.
408 pub(crate) fn title(self) -> Option<&'static str> {
409 match self {
410 Self::Auto => None,
411 Self::Chip => Some("chip"),
412 Self::Embed => Some("embed"),
413 }
414 }
415
416 pub(crate) fn from_title(title: &str) -> Option<Self> {
417 match title {
418 "chip" => Some(Self::Chip),
419 "embed" => Some(Self::Embed),
420 _ => None,
421 }
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Hash)]
426pub enum Mark {
427 Bold,
428 Italic,
429 Strike,
430 Code,
431 Link(String),
432 /// A link painted richly rather than as underlined text — a chip inline, a
433 /// [`BlockKind::Bookmark`] with a block to itself.
434 ///
435 /// The chip shows a favicon and the [`crate::preview`] title in place of
436 /// its text, and opens the card on hover. Its text is atomic: a caret sits
437 /// at either end, never inside, and typing at its end falls outside it.
438 Mention {
439 url: String,
440 form: Form,
441 },
442 /// A mark the app spells itself — underline, a highlight, a colour. The
443 /// name is [`crate::Marks`]'s, and the delimiter that writes it comes from
444 /// the same registry.
445 Custom(String),
446 /// An image among text. [`BlockKind::Image`] is the shape an editor offers;
447 /// this is what keeps `see  here` from silently becoming a link when
448 /// the document is saved. No width: one among text has no box of its own to
449 /// resize, and the `|480` that would say so stays the ordinary text it is.
450 Image(String),
451}