use std::sync::OnceLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
struct Shortcut {
pattern: &'static Regex,
block_type: &'static str,
props: Value,
}
macro_rules! lazy_regex {
($name:ident, $pat:expr) => {
fn $name() -> &'static Regex {
static CELL: OnceLock<Regex> = OnceLock::new();
CELL.get_or_init(|| Regex::new($pat).expect("valid regex"))
}
};
}
lazy_regex!(re_todo_empty, r"^\[\]\s$");
lazy_regex!(re_todo_empty_spaced, r"^\[ \]\s$");
lazy_regex!(re_todo_checked, r"^(?i)\[x\]\s$");
lazy_regex!(re_list_dash, r"^-\s$");
lazy_regex!(re_list_decimal, r"^1\.\s$");
lazy_regex!(re_list_roman_upper, r"^I\.\s$");
lazy_regex!(re_list_roman_lower, r"^i\.\s$");
lazy_regex!(re_list_latin_lower, r"^a\.\s$");
lazy_regex!(re_list_latin_upper, r"^A\.\s$");
lazy_regex!(re_list_korean, r"^가\.\s$");
lazy_regex!(re_list_korean_paren, r"^가\)\s$");
lazy_regex!(re_list_paren, r"^①\s$");
lazy_regex!(re_list_latin_lower_paren, r"^ⓐ\s$");
lazy_regex!(re_list_latin_upper_paren, r"^Ⓐ\s$");
lazy_regex!(re_toggle, r"^>\s$");
lazy_regex!(re_quote, r"^\|\s$");
lazy_regex!(re_h1, r"^#\s$");
lazy_regex!(re_h2, r"^##\s$");
lazy_regex!(re_h3, r"^###\s$");
fn shortcuts() -> &'static [Shortcut] {
static TABLE: OnceLock<Vec<Shortcut>> = OnceLock::new();
TABLE.get_or_init(|| {
vec![
Shortcut {
pattern: re_todo_empty(),
block_type: "todo",
props: json_obj([("checked", Value::Bool(false))]),
},
Shortcut {
pattern: re_todo_empty_spaced(),
block_type: "todo",
props: json_obj([("checked", Value::Bool(false))]),
},
Shortcut {
pattern: re_todo_checked(),
block_type: "todo",
props: json_obj([("checked", Value::Bool(true))]),
},
Shortcut {
pattern: re_list_dash(),
block_type: "list",
props: list_props("unordered"),
},
Shortcut {
pattern: re_list_decimal(),
block_type: "list",
props: list_props("ordered"),
},
Shortcut {
pattern: re_list_roman_upper(),
block_type: "list",
props: list_props("ordered-roman-upper"),
},
Shortcut {
pattern: re_list_roman_lower(),
block_type: "list",
props: list_props("ordered-roman-lower"),
},
Shortcut {
pattern: re_list_latin_lower(),
block_type: "list",
props: list_props("ordered-latin-lower"),
},
Shortcut {
pattern: re_list_latin_upper(),
block_type: "list",
props: list_props("ordered-latin-upper"),
},
Shortcut {
pattern: re_list_korean(),
block_type: "list",
props: list_props("ordered-korean"),
},
Shortcut {
pattern: re_list_korean_paren(),
block_type: "list",
props: list_props("ordered-korean-paren"),
},
Shortcut {
pattern: re_list_paren(),
block_type: "list",
props: list_props("ordered-paren"),
},
Shortcut {
pattern: re_list_latin_lower_paren(),
block_type: "list",
props: list_props("ordered-latin-lower-paren"),
},
Shortcut {
pattern: re_list_latin_upper_paren(),
block_type: "list",
props: list_props("ordered-latin-upper-paren"),
},
Shortcut {
pattern: re_toggle(),
block_type: "toggle",
props: json_obj([("collapsed", Value::Bool(false))]),
},
Shortcut {
pattern: re_quote(),
block_type: "quote",
props: Value::Object(Map::new()),
},
Shortcut {
pattern: re_h1(),
block_type: "heading",
props: json_obj([("level", Value::from(1u64))]),
},
Shortcut {
pattern: re_h2(),
block_type: "heading",
props: json_obj([("level", Value::from(2u64))]),
},
Shortcut {
pattern: re_h3(),
block_type: "heading",
props: json_obj([("level", Value::from(3u64))]),
},
]
})
}
fn list_props(style: &str) -> Value {
json_obj([
("style", Value::String(style.to_string())),
("indent", Value::from(0u64)),
])
}
fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
let mut map = Map::new();
for (k, v) in entries {
map.insert(k.to_string(), v);
}
Value::Object(map)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MarkdownMatch {
#[serde(rename = "prefixLen")]
pub prefix_len: usize,
#[serde(rename = "blockType")]
pub block_type: String,
pub props: Value,
}
#[must_use]
pub fn match_markdown_shortcut(block_text: &str, cursor_offset: usize) -> Option<MarkdownMatch> {
let prefix = prefix_by_utf16(block_text, cursor_offset);
for shortcut in shortcuts() {
if shortcut.pattern.is_match(&prefix) {
return Some(MarkdownMatch {
prefix_len: cursor_offset,
block_type: shortcut.block_type.to_string(),
props: shortcut.props.clone(),
});
}
}
None
}
fn prefix_by_utf16(s: &str, utf16_units: usize) -> String {
let byte_end = devup_editor_core::model::inline::utf16_to_byte(s, utf16_units);
s[..byte_end].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_match(text: &str, cursor: usize, expected_type: &str, expected_prop: (&str, Value)) {
let m = match_markdown_shortcut(text, cursor)
.unwrap_or_else(|| panic!("no match for {text:?} @ {cursor}"));
assert_eq!(m.block_type, expected_type);
assert_eq!(m.prefix_len, cursor);
let (k, v) = expected_prop;
assert_eq!(
m.props.get(k),
Some(&v),
"prop {k} mismatch in {:?}",
m.props
);
}
#[test]
fn heading_levels() {
assert_match("# ", 2, "heading", ("level", Value::from(1u64)));
assert_match("## ", 3, "heading", ("level", Value::from(2u64)));
assert_match("### ", 4, "heading", ("level", Value::from(3u64)));
}
#[test]
fn lists_basic() {
assert_match(
"- ",
2,
"list",
("style", Value::String("unordered".into())),
);
assert_match("1. ", 3, "list", ("style", Value::String("ordered".into())));
assert_match(
"I. ",
3,
"list",
("style", Value::String("ordered-roman-upper".into())),
);
assert_match(
"i. ",
3,
"list",
("style", Value::String("ordered-roman-lower".into())),
);
assert_match(
"A. ",
3,
"list",
("style", Value::String("ordered-latin-upper".into())),
);
assert_match(
"a. ",
3,
"list",
("style", Value::String("ordered-latin-lower".into())),
);
}
#[test]
fn lists_korean() {
assert_match(
"가. ",
3,
"list",
("style", Value::String("ordered-korean".into())),
);
assert_match(
"가) ",
3,
"list",
("style", Value::String("ordered-korean-paren".into())),
);
}
#[test]
fn lists_parenthesized_unicode() {
assert_match(
"① ",
2,
"list",
("style", Value::String("ordered-paren".into())),
);
assert_match(
"ⓐ ",
2,
"list",
("style", Value::String("ordered-latin-lower-paren".into())),
);
assert_match(
"Ⓐ ",
2,
"list",
("style", Value::String("ordered-latin-upper-paren".into())),
);
}
#[test]
fn todo_variants() {
assert_match("[] ", 3, "todo", ("checked", Value::Bool(false)));
assert_match("[ ] ", 4, "todo", ("checked", Value::Bool(false)));
assert_match("[x] ", 4, "todo", ("checked", Value::Bool(true)));
assert_match("[X] ", 4, "todo", ("checked", Value::Bool(true))); }
#[test]
fn toggle_and_quote() {
assert_match("> ", 2, "toggle", ("collapsed", Value::Bool(false)));
let m = match_markdown_shortcut("| ", 2).unwrap();
assert_eq!(m.block_type, "quote");
assert_eq!(m.props, Value::Object(Map::new()));
}
#[test]
fn no_match_on_non_shortcut() {
assert!(match_markdown_shortcut("hello ", 6).is_none());
assert!(match_markdown_shortcut("# heading text", 14).is_none());
assert!(match_markdown_shortcut("#", 1).is_none());
}
#[test]
fn cursor_in_middle_only_prefix_matters() {
assert_match("## xyz", 3, "heading", ("level", Value::from(2u64)));
}
#[test]
fn prefix_past_end_returns_whole_string() {
let m = match_markdown_shortcut("# ", 100);
assert!(m.is_some());
}
}