devup-editor-input-rules 1.0.20

Markdown shortcut + special-char + inline-mark matchers for devup-editor's input-rule pipeline
Documentation
//! Markdown shortcut matcher.
//!
//! Mirrors `MARKDOWN_SHORTCUTS` + `markdownShortcutRule.apply()` from the
//! React plugin. The TS side runs each regex against the prefix
//! `blockText.slice(0, cursor_offset)` on every space keystroke; we do
//! the same here with compiled `Regex` instances cached via `OnceLock`.

use std::sync::OnceLock;

use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

/// One entry in the shortcut table.
struct Shortcut {
    pattern: &'static Regex,
    block_type: &'static str,
    props: Value,
}

/// Compiled `Regex` built lazily on first access. Interior mutability
/// via `OnceLock` means zero runtime cost after the first match.
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)
}

/// Result of a successful markdown-shortcut match. The dispatch side
/// (React + WASM) turns this into `setSelection(0..prefix_len)` +
/// `Backspace` + `SetBlockType(block_type, props)`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MarkdownMatch {
    /// UTF-16 length of the matched prefix (the TS code passed
    /// `prefix.length` straight through, so this preserves the
    /// contract: selections on the React side use UTF-16 code units).
    #[serde(rename = "prefixLen")]
    pub prefix_len: usize,
    /// Target block type (`"heading"`, `"list"`, …).
    #[serde(rename = "blockType")]
    pub block_type: String,
    /// JSON object the editor should attach as the block's props.
    pub props: Value,
}

/// Match a markdown shortcut against `block_text` up to `cursor_offset`
/// (UTF-16 code-unit offset, same unit the engine uses for selections).
///
/// Returns `None` when nothing matches — the caller should NOT dispatch
/// anything. Returns `Some(MarkdownMatch)` describing the selection +
/// conversion to apply.
///
/// The `block_text` is expected to already be the entire paragraph's
/// text; we slice off `..cursor_offset` here to match the TS loop.
#[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
}

/// Slice a UTF-16 prefix from `s` of `utf16_units` code units.
/// Cursor offsets past the end clamp to the full string — matches the
/// TS `blockText.slice(0, cursorOffset)` behaviour.
///
/// Thin wrapper over
/// [`utf16_to_byte`](devup_editor_core::model::inline::utf16_to_byte)
/// so the UTF-16 traversal lives in exactly one place.
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() {
        // Post-special-char-replacement shortcuts.
        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))); // case-insensitive
    }

    #[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");
        // quote has empty props map
        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());
        // No match without trailing space
        assert!(match_markdown_shortcut("#", 1).is_none());
    }

    #[test]
    fn cursor_in_middle_only_prefix_matters() {
        // Even if "## xyz" is in block, cursor at 3 ("## ") should match h2.
        assert_match("## xyz", 3, "heading", ("level", Value::from(2u64)));
    }

    #[test]
    fn prefix_past_end_returns_whole_string() {
        // Safety: TS does `blockText.slice(0, cursorOffset)` which is
        // tolerant of offsets past the end; we must match that.
        let m = match_markdown_shortcut("# ", 100);
        assert!(m.is_some());
    }
}