Skip to main content

markdown/edit/
shortcut.rs

1//! Typed markdown prefixes and inline rules.
2
3use super::*;
4
5/// A markdown prefix typed at the start of a block, and what it turns it into.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Shortcut {
8    Heading(u8),
9    Bullet,
10    Ordered,
11    Task(bool),
12    Quote,
13    Code,
14    Rule,
15}
16
17impl Doc {
18    /// Turn block `ix` the way a typed prefix says, keeping its text. A quote
19    /// is the block's [`Block::quote`] and leaves its kind alone.
20    pub fn apply(&mut self, ix: usize, shortcut: Shortcut) {
21        let text = Text::default;
22        let kind = match shortcut {
23            Shortcut::Heading(level) => BlockKind::Heading {
24                level,
25                text: text(),
26            },
27            Shortcut::Bullet => BlockKind::Bullet(text()),
28            Shortcut::Ordered => BlockKind::Ordered {
29                number: 1,
30                text: text(),
31            },
32            Shortcut::Task(checked) => BlockKind::Task {
33                checked,
34                text: text(),
35            },
36            Shortcut::Quote => return self.set_quote(ix, Some(Quoted::default())),
37            Shortcut::Code => BlockKind::Code {
38                language: None,
39                code: text(),
40                height: None,
41            },
42            Shortcut::Rule => BlockKind::Rule,
43        };
44        self.set_kind(ix, kind);
45    }
46}
47
48/// Match a markdown prefix at the start of a block, returning it and how many
49/// bytes it occupied.
50///
51/// This is the input side of the same vocabulary [`crate::parse`] reads: typing
52/// `## ` makes a heading because pasting `## ` would have. Order matters — a
53/// task marker is a bullet with more on the end.
54pub fn shortcut(text: &str) -> Option<(Shortcut, usize)> {
55    const PREFIXES: &[(&str, Shortcut)] = &[
56        ("- [ ] ", Shortcut::Task(false)),
57        ("- [x] ", Shortcut::Task(true)),
58        ("###### ", Shortcut::Heading(6)),
59        ("##### ", Shortcut::Heading(5)),
60        ("#### ", Shortcut::Heading(4)),
61        ("### ", Shortcut::Heading(3)),
62        ("## ", Shortcut::Heading(2)),
63        ("# ", Shortcut::Heading(1)),
64        ("- ", Shortcut::Bullet),
65        ("* ", Shortcut::Bullet),
66        ("+ ", Shortcut::Bullet),
67        ("1. ", Shortcut::Ordered),
68        ("> ", Shortcut::Quote),
69        ("```", Shortcut::Code),
70        ("---", Shortcut::Rule),
71    ];
72    PREFIXES
73        .iter()
74        .find(|(prefix, _)| text.starts_with(prefix))
75        .map(|(prefix, shortcut)| (*shortcut, prefix.len()))
76}
77
78/// A closing inline delimiter just typed, and the run it closes.
79///
80/// The inline half of the same vocabulary [`shortcut`] covers: typing the last
81/// `*` of `**bold**` makes it bold because pasting `**bold**` would have.
82/// Returns the opening delimiter's range and the text between it and the caret;
83/// the closing delimiter is `inner.end..caret`.
84pub fn inline_rule(text: &str, caret: usize) -> Option<(Range<usize>, Range<usize>, Mark)> {
85    let head = text.get(..caret)?;
86    // Longest first — `**` is bold, and only what is left of it is italic.
87    for (delimiter, mark) in [
88        ("**", Mark::Bold),
89        ("__", Mark::Bold),
90        ("~~", Mark::Strike),
91        ("`", Mark::Code),
92        ("_", Mark::Italic),
93        ("*", Mark::Italic),
94    ] {
95        let Some(closes) = head.strip_suffix(delimiter) else {
96            continue;
97        };
98        let Some(open) = closes.rfind(delimiter) else {
99            continue;
100        };
101        // `**bold*` is one keystroke from closing. Reading its second opening
102        // star as italic's spends it, and the star still to come then finds no
103        // `**` to close. The same holds for `__bold_`.
104        if matches!(delimiter, "*" | "_") && text[..open].ends_with(delimiter) {
105            continue;
106        }
107        let inner = open + delimiter.len()..closes.len();
108        let Some(body) = text.get(inner.clone()).filter(|body| !body.is_empty()) else {
109            continue;
110        };
111        // Emphasis cannot open or close against whitespace, so a mark reaching
112        // over one has no spelling and [`Text::normalize_marks`] would shrink
113        // it straight back off. A rule that fires and vanishes is worse than
114        // one that does not fire.
115        if body.starts_with(char::is_whitespace) || body.ends_with(char::is_whitespace) {
116            continue;
117        }
118        // An underscore inside a word is not emphasis in CommonMark, which is
119        // the only reason `snake_case_names` survive being typed.
120        if delimiter.starts_with('_')
121            && text[..open]
122                .chars()
123                .next_back()
124                .is_some_and(char::is_alphanumeric)
125        {
126            continue;
127        }
128        return Some((open..open + delimiter.len(), inner, mark));
129    }
130    None
131}