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