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