1#![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#[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
154fn 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
169fn 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
254fn 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}