Skip to main content

mermaid_cli/domain/
image_token.rs

1//! Inline `[Image #N]` tokens for the composer.
2//!
3//! A pasted image becomes an inline text token — `"[Image #N] "` — spliced into
4//! the input buffer at the cursor (see `reducer::handle_paste`), backed by an
5//! `Attachment` carrying the same global `number`. The token in the text is the
6//! source of truth at submit time; `ui.attachments` is just the base64 store
7//! keyed by that number. This module is the single home for the token's textual
8//! form and for locating/parsing tokens in a buffer, so the reducer's
9//! atomic-delete and submit-reconciliation paths never open-code the format.
10
11use std::sync::OnceLock;
12
13use regex::Regex;
14
15/// `[Image #<digits>]` — the canonical pill shape. The trailing space that
16/// [`render_token`] appends is deliberately NOT part of the match, so deleting a
17/// pill leaves surrounding spacing to the normal editing rules.
18fn token_re() -> &'static Regex {
19    static RE: OnceLock<Regex> = OnceLock::new();
20    RE.get_or_init(|| Regex::new(r"\[Image #(\d+)\]").expect("valid image-token regex"))
21}
22
23/// Inline text inserted at paste time for image `n`, e.g. `"[Image #7] "`. The
24/// trailing space lets the user keep typing and makes the pill delete in two
25/// predictable keystrokes (space, then pill).
26pub fn render_token(n: u64) -> String {
27    format!("[Image #{n}] ")
28}
29
30/// If a complete `[Image #N]` token ends exactly at byte offset `cursor`, return
31/// `(token_start, N)`. Drives atomic Backspace: the whole pill (and its image)
32/// go together. A number that overflows `u64` is treated as a non-match (falls
33/// back to a normal character delete).
34pub fn token_ending_at(buf: &str, cursor: usize) -> Option<(usize, u64)> {
35    token_re().captures_iter(buf).find_map(|c| {
36        let whole = c.get(0)?;
37        if whole.end() != cursor {
38            return None;
39        }
40        let n = c.get(1)?.as_str().parse::<u64>().ok()?;
41        Some((whole.start(), n))
42    })
43}
44
45/// If a complete `[Image #N]` token starts exactly at byte offset `cursor`,
46/// return `(token_end, N)`. Symmetric to [`token_ending_at`] for forward-Delete.
47pub fn token_starting_at(buf: &str, cursor: usize) -> Option<(usize, u64)> {
48    token_re().captures_iter(buf).find_map(|c| {
49        let whole = c.get(0)?;
50        if whole.start() != cursor {
51            return None;
52        }
53        let n = c.get(1)?.as_str().parse::<u64>().ok()?;
54        Some((whole.end(), n))
55    })
56}
57
58/// Image numbers referenced by `[Image #N]` tokens in `text`, in
59/// first-appearance order, de-duplicated. Drives which attachments are sent, and
60/// in what order, at submit time. Numbers that overflow `u64` are skipped.
61pub fn numbers_in_order(text: &str) -> Vec<u64> {
62    let mut out = Vec::new();
63    for c in token_re().captures_iter(text) {
64        if let Some(n) = c.get(1).and_then(|m| m.as_str().parse::<u64>().ok())
65            && !out.contains(&n)
66        {
67            out.push(n);
68        }
69    }
70    out
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn render_token_has_trailing_space() {
79        assert_eq!(render_token(7), "[Image #7] ");
80        assert_eq!(render_token(16), "[Image #16] ");
81    }
82
83    #[test]
84    fn token_ending_at_hits_only_the_exact_boundary() {
85        let buf = "see [Image #3] here";
86        // "[Image #3]" spans bytes 4..14; it ends at 14.
87        assert_eq!(token_ending_at(buf, 14), Some((4, 3)));
88        // Off by one either way is a miss.
89        assert_eq!(token_ending_at(buf, 13), None);
90        assert_eq!(token_ending_at(buf, 15), None);
91        // A cursor inside the token is not an ending boundary.
92        assert_eq!(token_ending_at(buf, 9), None);
93    }
94
95    #[test]
96    fn token_ending_at_picks_the_right_one_among_many() {
97        let buf = "[Image #1][Image #2]";
98        assert_eq!(token_ending_at(buf, 10), Some((0, 1))); // end of first
99        assert_eq!(token_ending_at(buf, 20), Some((10, 2))); // end of second
100    }
101
102    #[test]
103    fn token_starting_at_is_symmetric() {
104        let buf = "[Image #2] a [Image #5]";
105        assert_eq!(token_starting_at(buf, 0), Some((10, 2)));
106        assert_eq!(token_starting_at(buf, 13), Some((23, 5)));
107        assert_eq!(token_starting_at(buf, 1), None);
108    }
109
110    #[test]
111    fn numbers_in_order_dedups_and_preserves_first_appearance() {
112        assert_eq!(numbers_in_order("hello"), Vec::<u64>::new());
113        assert_eq!(numbers_in_order("[Image #1]"), vec![1]);
114        // Out-of-order stays in text order.
115        assert_eq!(numbers_in_order("[Image #5] x [Image #3]"), vec![5, 3]);
116        // Duplicate collapses to a single entry at first appearance.
117        assert_eq!(numbers_in_order("[Image #2] [Image #2]"), vec![2]);
118        assert_eq!(
119            numbers_in_order("a [Image #7] b [Image #7] c [Image #1]"),
120            vec![7, 1]
121        );
122    }
123
124    #[test]
125    fn overflowing_numbers_are_ignored_not_panicked() {
126        let huge = "[Image #99999999999999999999999999]"; // > u64::MAX
127        assert_eq!(numbers_in_order(huge), Vec::<u64>::new());
128        assert_eq!(token_ending_at(huge, huge.len()), None);
129    }
130
131    #[test]
132    fn non_token_brackets_are_not_matched() {
133        assert_eq!(
134            numbers_in_order("[Image] [Img #3] [Image #x]"),
135            Vec::<u64>::new()
136        );
137    }
138}