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