Skip to main content

devup_editor_input_rules/
markdown.rs

1//! Markdown shortcut matcher.
2//!
3//! Mirrors `MARKDOWN_SHORTCUTS` + `markdownShortcutRule.apply()` from the
4//! React plugin. The TS side runs each regex against the prefix
5//! `blockText.slice(0, cursor_offset)` on every space keystroke; we do
6//! the same here with compiled `Regex` instances cached via `OnceLock`.
7
8use std::sync::OnceLock;
9
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13
14/// One entry in the shortcut table.
15struct Shortcut {
16    pattern: &'static Regex,
17    block_type: &'static str,
18    props: Value,
19}
20
21/// Compiled `Regex` built lazily on first access. Interior mutability
22/// via `OnceLock` means zero runtime cost after the first match.
23macro_rules! lazy_regex {
24    ($name:ident, $pat:expr) => {
25        fn $name() -> &'static Regex {
26            static CELL: OnceLock<Regex> = OnceLock::new();
27            CELL.get_or_init(|| Regex::new($pat).expect("valid regex"))
28        }
29    };
30}
31
32lazy_regex!(re_todo_empty, r"^\[\]\s$");
33lazy_regex!(re_todo_empty_spaced, r"^\[ \]\s$");
34lazy_regex!(re_todo_checked, r"^(?i)\[x\]\s$");
35lazy_regex!(re_list_dash, r"^-\s$");
36lazy_regex!(re_list_decimal, r"^1\.\s$");
37lazy_regex!(re_list_roman_upper, r"^I\.\s$");
38lazy_regex!(re_list_roman_lower, r"^i\.\s$");
39lazy_regex!(re_list_latin_lower, r"^a\.\s$");
40lazy_regex!(re_list_latin_upper, r"^A\.\s$");
41lazy_regex!(re_list_korean, r"^가\.\s$");
42lazy_regex!(re_list_korean_paren, r"^가\)\s$");
43lazy_regex!(re_list_paren, r"^①\s$");
44lazy_regex!(re_list_latin_lower_paren, r"^ⓐ\s$");
45lazy_regex!(re_list_latin_upper_paren, r"^Ⓐ\s$");
46lazy_regex!(re_toggle, r"^>\s$");
47lazy_regex!(re_quote, r"^\|\s$");
48lazy_regex!(re_h1, r"^#\s$");
49lazy_regex!(re_h2, r"^##\s$");
50lazy_regex!(re_h3, r"^###\s$");
51
52fn shortcuts() -> &'static [Shortcut] {
53    static TABLE: OnceLock<Vec<Shortcut>> = OnceLock::new();
54    TABLE.get_or_init(|| {
55        vec![
56            Shortcut {
57                pattern: re_todo_empty(),
58                block_type: "todo",
59                props: json_obj([("checked", Value::Bool(false))]),
60            },
61            Shortcut {
62                pattern: re_todo_empty_spaced(),
63                block_type: "todo",
64                props: json_obj([("checked", Value::Bool(false))]),
65            },
66            Shortcut {
67                pattern: re_todo_checked(),
68                block_type: "todo",
69                props: json_obj([("checked", Value::Bool(true))]),
70            },
71            Shortcut {
72                pattern: re_list_dash(),
73                block_type: "list",
74                props: list_props("unordered"),
75            },
76            Shortcut {
77                pattern: re_list_decimal(),
78                block_type: "list",
79                props: list_props("ordered"),
80            },
81            Shortcut {
82                pattern: re_list_roman_upper(),
83                block_type: "list",
84                props: list_props("ordered-roman-upper"),
85            },
86            Shortcut {
87                pattern: re_list_roman_lower(),
88                block_type: "list",
89                props: list_props("ordered-roman-lower"),
90            },
91            Shortcut {
92                pattern: re_list_latin_lower(),
93                block_type: "list",
94                props: list_props("ordered-latin-lower"),
95            },
96            Shortcut {
97                pattern: re_list_latin_upper(),
98                block_type: "list",
99                props: list_props("ordered-latin-upper"),
100            },
101            Shortcut {
102                pattern: re_list_korean(),
103                block_type: "list",
104                props: list_props("ordered-korean"),
105            },
106            Shortcut {
107                pattern: re_list_korean_paren(),
108                block_type: "list",
109                props: list_props("ordered-korean-paren"),
110            },
111            Shortcut {
112                pattern: re_list_paren(),
113                block_type: "list",
114                props: list_props("ordered-paren"),
115            },
116            Shortcut {
117                pattern: re_list_latin_lower_paren(),
118                block_type: "list",
119                props: list_props("ordered-latin-lower-paren"),
120            },
121            Shortcut {
122                pattern: re_list_latin_upper_paren(),
123                block_type: "list",
124                props: list_props("ordered-latin-upper-paren"),
125            },
126            Shortcut {
127                pattern: re_toggle(),
128                block_type: "toggle",
129                props: json_obj([("collapsed", Value::Bool(false))]),
130            },
131            Shortcut {
132                pattern: re_quote(),
133                block_type: "quote",
134                props: Value::Object(Map::new()),
135            },
136            Shortcut {
137                pattern: re_h1(),
138                block_type: "heading",
139                props: json_obj([("level", Value::from(1u64))]),
140            },
141            Shortcut {
142                pattern: re_h2(),
143                block_type: "heading",
144                props: json_obj([("level", Value::from(2u64))]),
145            },
146            Shortcut {
147                pattern: re_h3(),
148                block_type: "heading",
149                props: json_obj([("level", Value::from(3u64))]),
150            },
151        ]
152    })
153}
154
155fn list_props(style: &str) -> Value {
156    json_obj([
157        ("style", Value::String(style.to_string())),
158        ("indent", Value::from(0u64)),
159    ])
160}
161
162fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
163    let mut map = Map::new();
164    for (k, v) in entries {
165        map.insert(k.to_string(), v);
166    }
167    Value::Object(map)
168}
169
170/// Result of a successful markdown-shortcut match. The dispatch side
171/// (React + WASM) turns this into `setSelection(0..prefix_len)` +
172/// `Backspace` + `SetBlockType(block_type, props)`.
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174pub struct MarkdownMatch {
175    /// UTF-16 length of the matched prefix (the TS code passed
176    /// `prefix.length` straight through, so this preserves the
177    /// contract: selections on the React side use UTF-16 code units).
178    #[serde(rename = "prefixLen")]
179    pub prefix_len: usize,
180    /// Target block type (`"heading"`, `"list"`, …).
181    #[serde(rename = "blockType")]
182    pub block_type: String,
183    /// JSON object the editor should attach as the block's props.
184    pub props: Value,
185}
186
187/// Match a markdown shortcut against `block_text` up to `cursor_offset`
188/// (UTF-16 code-unit offset, same unit the engine uses for selections).
189///
190/// Returns `None` when nothing matches — the caller should NOT dispatch
191/// anything. Returns `Some(MarkdownMatch)` describing the selection +
192/// conversion to apply.
193///
194/// The `block_text` is expected to already be the entire paragraph's
195/// text; we slice off `..cursor_offset` here to match the TS loop.
196#[must_use]
197pub fn match_markdown_shortcut(block_text: &str, cursor_offset: usize) -> Option<MarkdownMatch> {
198    let prefix = prefix_by_utf16(block_text, cursor_offset);
199    for shortcut in shortcuts() {
200        if shortcut.pattern.is_match(&prefix) {
201            return Some(MarkdownMatch {
202                prefix_len: cursor_offset,
203                block_type: shortcut.block_type.to_string(),
204                props: shortcut.props.clone(),
205            });
206        }
207    }
208    None
209}
210
211/// Slice a UTF-16 prefix from `s` of `utf16_units` code units.
212/// Cursor offsets past the end clamp to the full string — matches the
213/// TS `blockText.slice(0, cursorOffset)` behaviour.
214///
215/// Thin wrapper over
216/// [`utf16_to_byte`](devup_editor_core::model::inline::utf16_to_byte)
217/// so the UTF-16 traversal lives in exactly one place.
218fn prefix_by_utf16(s: &str, utf16_units: usize) -> String {
219    let byte_end = devup_editor_core::model::inline::utf16_to_byte(s, utf16_units);
220    s[..byte_end].to_string()
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn assert_match(text: &str, cursor: usize, expected_type: &str, expected_prop: (&str, Value)) {
228        let m = match_markdown_shortcut(text, cursor)
229            .unwrap_or_else(|| panic!("no match for {text:?} @ {cursor}"));
230        assert_eq!(m.block_type, expected_type);
231        assert_eq!(m.prefix_len, cursor);
232        let (k, v) = expected_prop;
233        assert_eq!(
234            m.props.get(k),
235            Some(&v),
236            "prop {k} mismatch in {:?}",
237            m.props
238        );
239    }
240
241    #[test]
242    fn heading_levels() {
243        assert_match("# ", 2, "heading", ("level", Value::from(1u64)));
244        assert_match("## ", 3, "heading", ("level", Value::from(2u64)));
245        assert_match("### ", 4, "heading", ("level", Value::from(3u64)));
246    }
247
248    #[test]
249    fn lists_basic() {
250        assert_match(
251            "- ",
252            2,
253            "list",
254            ("style", Value::String("unordered".into())),
255        );
256        assert_match("1. ", 3, "list", ("style", Value::String("ordered".into())));
257        assert_match(
258            "I. ",
259            3,
260            "list",
261            ("style", Value::String("ordered-roman-upper".into())),
262        );
263        assert_match(
264            "i. ",
265            3,
266            "list",
267            ("style", Value::String("ordered-roman-lower".into())),
268        );
269        assert_match(
270            "A. ",
271            3,
272            "list",
273            ("style", Value::String("ordered-latin-upper".into())),
274        );
275        assert_match(
276            "a. ",
277            3,
278            "list",
279            ("style", Value::String("ordered-latin-lower".into())),
280        );
281    }
282
283    #[test]
284    fn lists_korean() {
285        assert_match(
286            "가. ",
287            3,
288            "list",
289            ("style", Value::String("ordered-korean".into())),
290        );
291        assert_match(
292            "가) ",
293            3,
294            "list",
295            ("style", Value::String("ordered-korean-paren".into())),
296        );
297    }
298
299    #[test]
300    fn lists_parenthesized_unicode() {
301        // Post-special-char-replacement shortcuts.
302        assert_match(
303            "① ",
304            2,
305            "list",
306            ("style", Value::String("ordered-paren".into())),
307        );
308        assert_match(
309            "ⓐ ",
310            2,
311            "list",
312            ("style", Value::String("ordered-latin-lower-paren".into())),
313        );
314        assert_match(
315            "Ⓐ ",
316            2,
317            "list",
318            ("style", Value::String("ordered-latin-upper-paren".into())),
319        );
320    }
321
322    #[test]
323    fn todo_variants() {
324        assert_match("[] ", 3, "todo", ("checked", Value::Bool(false)));
325        assert_match("[ ] ", 4, "todo", ("checked", Value::Bool(false)));
326        assert_match("[x] ", 4, "todo", ("checked", Value::Bool(true)));
327        assert_match("[X] ", 4, "todo", ("checked", Value::Bool(true))); // case-insensitive
328    }
329
330    #[test]
331    fn toggle_and_quote() {
332        assert_match("> ", 2, "toggle", ("collapsed", Value::Bool(false)));
333        let m = match_markdown_shortcut("| ", 2).unwrap();
334        assert_eq!(m.block_type, "quote");
335        // quote has empty props map
336        assert_eq!(m.props, Value::Object(Map::new()));
337    }
338
339    #[test]
340    fn no_match_on_non_shortcut() {
341        assert!(match_markdown_shortcut("hello ", 6).is_none());
342        assert!(match_markdown_shortcut("# heading text", 14).is_none());
343        // No match without trailing space
344        assert!(match_markdown_shortcut("#", 1).is_none());
345    }
346
347    #[test]
348    fn cursor_in_middle_only_prefix_matters() {
349        // Even if "## xyz" is in block, cursor at 3 ("## ") should match h2.
350        assert_match("## xyz", 3, "heading", ("level", Value::from(2u64)));
351    }
352
353    #[test]
354    fn prefix_past_end_returns_whole_string() {
355        // Safety: TS does `blockText.slice(0, cursorOffset)` which is
356        // tolerant of offsets past the end; we must match that.
357        let m = match_markdown_shortcut("# ", 100);
358        assert!(m.is_some());
359    }
360}