Skip to main content

pathland_markdown/
lib.rs

1//! Shared inline Markdown parser for Pathland.
2//!
3//! Parses a small inline subset into a
4//! [`pathland_core::AttributedString`]:
5//!
6//! - `**bold**` / `__bold__` → `FONT_WEIGHT` 700
7//! - `*italic*` / `_italic_` → `FONT_STYLE` Italic
8//! - `` `code` `` → `FONT_DESIGN` Monospaced
9//! - `~~strikethrough~~` → `STRIKETHROUGH` 1
10//! - `[label](url)` → link run (label text is parsed recursively)
11//! - `\` escapes the next character
12//!
13//! Intraword underscores stay literal (`snake_case_name`), matching CommonMark
14//! emphasis rules.
15//!
16//! The parser is `no_std` + `alloc` and is shared by every DSL: the Rust DSL
17//! calls [`parse`] directly; foreign hosts (Java/JNA) call the C ABI exported
18//! by the `pathland-markdown-capi` cdylib. Block-level Markdown, images, and
19//! reference links are out of scope for v1.
20
21#![no_std]
22
23extern crate alloc;
24
25#[cfg(test)]
26extern crate std;
27
28use alloc::string::String;
29use alloc::vec::Vec;
30use pathland_core::{AttributedString, LinkRun, StyleRun};
31
32/// Parse inline Markdown into an [`AttributedString`].
33///
34/// Returns `None` only if the produced ranges fail the model's validation
35/// (which should not happen for well-formed UTF-8 input; the parser never
36/// splits code points).
37#[must_use]
38pub fn parse(input: &str) -> Option<AttributedString> {
39    let mut text = String::new();
40    let mut runs = Vec::new();
41    let mut links = Vec::new();
42    parse_into(input, &mut text, &mut runs, &mut links);
43    AttributedString::new(text, runs, links).ok()
44}
45
46fn parse_into(input: &str, text: &mut String, runs: &mut Vec<StyleRun>, links: &mut Vec<LinkRun>) {
47    let bytes = input.as_bytes();
48    let mut i = 0;
49    while i < bytes.len() {
50        match bytes[i] {
51            b'\\' => {
52                let next = next_char(input, i + 1);
53                match next {
54                    Some((ch, end)) => {
55                        text.push(ch);
56                        i = end;
57                    }
58                    None => {
59                        text.push('\\');
60                        i += 1;
61                    }
62                }
63            }
64            b'*' if bytes.get(i + 1) == Some(&b'*') => {
65                if let Some(end) = find_delimiter(input, i + 2, "**") {
66                    let start = text.len();
67                    parse_into(&input[i + 2..end], text, runs, links);
68                    runs.push(StyleRun::new(byte_len(start), byte_len(text.len())).bold());
69                    i = end + 2;
70                } else {
71                    text.push('*');
72                    i += 1;
73                }
74            }
75            b'*' => {
76                if let Some(end) = find_delimiter(input, i + 1, "*") {
77                    let start = text.len();
78                    parse_into(&input[i + 1..end], text, runs, links);
79                    runs.push(StyleRun::new(byte_len(start), byte_len(text.len())).italic());
80                    i = end + 1;
81                } else {
82                    text.push('*');
83                    i += 1;
84                }
85            }
86            b'_' if bytes.get(i + 1) == Some(&b'_') && underscore_can_open(input, i) => {
87                if let Some(end) = find_delimiter(input, i + 2, "__") {
88                    let start = text.len();
89                    parse_into(&input[i + 2..end], text, runs, links);
90                    runs.push(StyleRun::new(byte_len(start), byte_len(text.len())).bold());
91                    i = end + 2;
92                } else {
93                    text.push('_');
94                    i += 1;
95                }
96            }
97            b'_' if underscore_can_open(input, i) => {
98                if let Some(end) = find_delimiter(input, i + 1, "_") {
99                    let start = text.len();
100                    parse_into(&input[i + 1..end], text, runs, links);
101                    runs.push(StyleRun::new(byte_len(start), byte_len(text.len())).italic());
102                    i = end + 1;
103                } else {
104                    text.push('_');
105                    i += 1;
106                }
107            }
108            b'~' if bytes.get(i + 1) == Some(&b'~') => {
109                if let Some(end) = find_delimiter(input, i + 2, "~~") {
110                    let start = text.len();
111                    parse_into(&input[i + 2..end], text, runs, links);
112                    runs.push(StyleRun::new(byte_len(start), byte_len(text.len())).strikethrough());
113                    i = end + 2;
114                } else {
115                    text.push('~');
116                    i += 1;
117                }
118            }
119            b'`' => {
120                if let Some(end) = find_delimiter(input, i + 1, "`") {
121                    let start = text.len();
122                    text.push_str(&input[i + 1..end]);
123                    runs.push(StyleRun::new(byte_len(start), byte_len(text.len())).monospaced());
124                    i = end + 1;
125                } else {
126                    text.push('`');
127                    i += 1;
128                }
129            }
130            b'[' => {
131                if let Some((label_end, url, after)) = parse_link(input, i) {
132                    let start = text.len();
133                    parse_into(&input[i + 1..label_end], text, runs, links);
134                    links.push(LinkRun::new(byte_len(start), byte_len(text.len()), url));
135                    i = after;
136                } else {
137                    text.push('[');
138                    i += 1;
139                }
140            }
141            _ => {
142                let (ch, end) = next_char(input, i).expect("valid utf-8 boundary");
143                text.push(ch);
144                i = end;
145            }
146        }
147    }
148}
149
150fn byte_len(value: usize) -> u32 {
151    u32::try_from(value).unwrap_or(u32::MAX)
152}
153
154/// Underscore emphasis is not allowed intraword (`snake_case` stays literal).
155fn underscore_can_open(input: &str, at: usize) -> bool {
156    input[..at]
157        .chars()
158        .next_back()
159        .is_none_or(|c| !c.is_alphanumeric())
160}
161
162fn underscore_can_close(input: &str, after_run: usize) -> bool {
163    input[after_run..]
164        .chars()
165        .next()
166        .is_none_or(|c| !c.is_alphanumeric())
167}
168
169/// Find the next closing delimiter at or after `from`, returning its byte
170/// offset. Escaped characters are skipped.
171///
172/// Asterisk runs are split so nesting works: for a run of three (`***`) the
173/// outer `**` close uses the last two stars and the inner `*` close uses the
174/// first. A lone `*` never closes on a `**` run (that is nested bold).
175fn find_delimiter(input: &str, from: usize, needle: &str) -> Option<usize> {
176    let bytes = input.as_bytes();
177    let mut i = from;
178    while i < bytes.len() {
179        if bytes[i] == b'\\' {
180            i += 2;
181            continue;
182        }
183        match needle {
184            "**" => {
185                if bytes[i] == b'*' {
186                    let mut end = i;
187                    while end < bytes.len() && bytes[end] == b'*' {
188                        end += 1;
189                    }
190                    if end - i >= 2 {
191                        return Some(end - 2);
192                    }
193                }
194            }
195            "*" => {
196                if bytes[i] == b'*' {
197                    let mut end = i;
198                    while end < bytes.len() && bytes[end] == b'*' {
199                        end += 1;
200                    }
201                    if end - i == 1 {
202                        return Some(i);
203                    }
204                    i = end;
205                    continue;
206                }
207            }
208            "__" => {
209                if bytes[i] == b'_' {
210                    let mut end = i;
211                    while end < bytes.len() && bytes[end] == b'_' {
212                        end += 1;
213                    }
214                    if end - i >= 2 && underscore_can_close(input, end) {
215                        return Some(end - 2);
216                    }
217                }
218            }
219            "_" => {
220                if bytes[i] == b'_' {
221                    let mut end = i;
222                    while end < bytes.len() && bytes[end] == b'_' {
223                        end += 1;
224                    }
225                    if end - i == 1 && underscore_can_close(input, end) {
226                        return Some(i);
227                    }
228                    i = end;
229                    continue;
230                }
231            }
232            "~~" => {
233                if bytes[i] == b'~' {
234                    let mut end = i;
235                    while end < bytes.len() && bytes[end] == b'~' {
236                        end += 1;
237                    }
238                    if end - i >= 2 {
239                        return Some(end - 2);
240                    }
241                }
242            }
243            _ => {
244                if input[i..].starts_with(needle) {
245                    return Some(i);
246                }
247            }
248        }
249        i = next_char(input, i)?.1;
250    }
251    None
252}
253
254/// Parse `[label](url)` at `open`. Returns `(label_end, url, after_close)`.
255fn parse_link(input: &str, open: usize) -> Option<(usize, &str, usize)> {
256    let bytes = input.as_bytes();
257    let mut i = open + 1;
258    while i < bytes.len() {
259        if bytes[i] == b'\\' {
260            i += 2;
261            continue;
262        }
263        if bytes[i] == b']' && bytes.get(i + 1) == Some(&b'(') {
264            let label_end = i;
265            let url_start = i + 2;
266            let mut j = url_start;
267            while j < bytes.len() {
268                if bytes[j] == b'\\' {
269                    j += 2;
270                    continue;
271                }
272                if bytes[j] == b')' {
273                    return Some((label_end, &input[url_start..j], j + 1));
274                }
275                j = next_char(input, j)?.1;
276            }
277            return None;
278        }
279        i = next_char(input, i)?.1;
280    }
281    None
282}
283
284fn next_char(input: &str, at: usize) -> Option<(char, usize)> {
285    let ch = input[at..].chars().next()?;
286    Some((ch, at + ch.len_utf8()))
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use pathland_core::{property_id, value_type};
293
294    fn decoded(markdown: &str) -> AttributedString {
295        parse(markdown).expect("parser output is valid")
296    }
297
298    #[test]
299    fn plain_text_has_no_runs() {
300        let value = decoded("hello");
301        assert_eq!(value.text(), "hello");
302        assert!(value.runs().is_empty());
303        assert!(value.links().is_empty());
304    }
305
306    #[test]
307    fn maps_each_inline_marker() {
308        let value = decoded("**b** *i* `c` ~~s~~");
309        assert_eq!(value.text(), "b i c s");
310        assert_eq!(value.runs().len(), 4);
311        assert_eq!(value.runs()[0].start, 0);
312        assert_eq!(value.runs()[0].end, 1);
313        assert!(value.runs()[0]
314            .attributes
315            .iter()
316            .any(|a| { a.property == property_id::FONT_WEIGHT && a.value == 700.0f32.to_bits() }));
317        assert!(value.runs()[1]
318            .attributes
319            .iter()
320            .any(|a| { a.property == property_id::FONT_STYLE && a.value == 1.0f32.to_bits() }));
321        assert!(value.runs()[2]
322            .attributes
323            .iter()
324            .any(|a| { a.property == property_id::FONT_DESIGN && a.value == 3.0f32.to_bits() }));
325        assert!(value.runs()[3].attributes.iter().any(|a| {
326            a.property == property_id::STRIKETHROUGH && a.value_type == value_type::U8
327        }));
328    }
329
330    #[test]
331    fn nests_formats() {
332        let value = decoded("**bold *and italic***");
333        assert_eq!(value.text(), "bold and italic");
334        assert_eq!(value.runs().len(), 2);
335        assert_eq!(value.runs()[0].start, 5);
336        assert_eq!(value.runs()[0].end, 15);
337        assert_eq!(value.runs()[1].start, 0);
338        assert_eq!(value.runs()[1].end, 15);
339    }
340
341    #[test]
342    fn links_carry_label_runs_and_url() {
343        let value = decoded("[**hi**](https://example.com)");
344        assert_eq!(value.text(), "hi");
345        assert_eq!(value.links().len(), 1);
346        assert_eq!(value.links()[0].url, "https://example.com");
347        assert_eq!(value.links()[0].start, 0);
348        assert_eq!(value.links()[0].end, 2);
349        assert_eq!(value.runs().len(), 1);
350    }
351
352    #[test]
353    fn code_spans_do_not_parse_inner_markup() {
354        let value = decoded("`**not bold**`");
355        assert_eq!(value.text(), "**not bold**");
356        assert_eq!(value.runs().len(), 1);
357        assert_eq!(
358            value.runs()[0].attributes[0].property,
359            property_id::FONT_DESIGN
360        );
361    }
362
363    #[test]
364    fn underscore_emphasis_matches_asterisk_forms() {
365        let value = decoded("_italic_ and __bold__");
366        assert_eq!(value.text(), "italic and bold");
367        assert_eq!(value.runs().len(), 2);
368        assert!(value.runs()[0]
369            .attributes
370            .iter()
371            .any(|a| a.property == property_id::FONT_STYLE));
372        assert!(value.runs()[1]
373            .attributes
374            .iter()
375            .any(|a| a.property == property_id::FONT_WEIGHT));
376    }
377
378    #[test]
379    fn intraword_underscores_stay_literal() {
380        let value = decoded("snake_case_name");
381        assert_eq!(value.text(), "snake_case_name");
382        assert!(value.runs().is_empty());
383    }
384
385    #[test]
386    fn escaped_underscores_are_literal() {
387        let value = decoded(r"\_not italic\_");
388        assert_eq!(value.text(), "_not italic_");
389        assert!(value.runs().is_empty());
390    }
391
392    #[test]
393    fn escapes_are_literal() {
394        let value = decoded(r"\*not italic\*");
395        assert_eq!(value.text(), "*not italic*");
396        assert!(value.runs().is_empty());
397    }
398
399    #[test]
400    fn unmatched_markers_stay_literal() {
401        let value = decoded("a * b ** c");
402        assert_eq!(value.text(), "a * b ** c");
403        assert!(value.runs().is_empty());
404    }
405
406    #[test]
407    fn utf8_offsets_are_bytes() {
408        let value = decoded("é**ü**");
409        assert_eq!(value.text(), "éü");
410        assert_eq!(value.runs()[0].start, 2);
411        assert_eq!(value.runs()[0].end, 4);
412    }
413}