1use std::sync::OnceLock;
9
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13
14struct Shortcut {
16 pattern: &'static Regex,
17 block_type: &'static str,
18 props: Value,
19}
20
21macro_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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174pub struct MarkdownMatch {
175 #[serde(rename = "prefixLen")]
179 pub prefix_len: usize,
180 #[serde(rename = "blockType")]
182 pub block_type: String,
183 pub props: Value,
185}
186
187#[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
211fn 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 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))); }
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 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 assert!(match_markdown_shortcut("#", 1).is_none());
345 }
346
347 #[test]
348 fn cursor_in_middle_only_prefix_matters() {
349 assert_match("## xyz", 3, "heading", ("level", Value::from(2u64)));
351 }
352
353 #[test]
354 fn prefix_past_end_returns_whole_string() {
355 let m = match_markdown_shortcut("# ", 100);
358 assert!(m.is_some());
359 }
360}