1use std::fmt::{Display, Formatter};
2
3pub fn generate_heading_slug(text: &str) -> String {
5 text.trim()
6 .chars()
7 .filter_map(|c| {
8 if c.is_alphanumeric() || c == '-' || c == '_' {
9 Some(c.to_lowercase().next().unwrap_or(c))
10 } else if c == ' ' {
11 Some('-')
12 } else {
13 None
14 }
15 })
16 .collect()
17}
18
19fn has_uri_scheme(url: &str) -> bool {
21 let mut chars = url.chars();
22 match chars.next() {
23 Some(c) if c.is_ascii_alphabetic() => {}
24 _ => return false,
25 }
26 for c in chars {
27 if c == ':' {
28 return true;
29 }
30 if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
31 return false;
32 }
33 }
34 false
35}
36
37pub fn split_local_url_fragment(url: &str) -> (&str, Option<&str>) {
40 if has_uri_scheme(url) {
41 return (url, None);
42 }
43 match url.find('#') {
44 Some(pos) => {
45 let path = &url[..pos];
46 let fragment = &url[pos + 1..];
47 (
48 path,
49 if fragment.is_empty() {
50 None
51 } else {
52 Some(fragment)
53 },
54 )
55 }
56 None => (url, None),
57 }
58}
59
60pub fn source_position_from_fragment(fragment: &str) -> Option<(u32, u32)> {
61 let fragment = fragment.strip_prefix('L').unwrap_or(fragment);
62 let fragment = fragment
63 .split_once('-')
64 .map_or(fragment, |(start, _)| start);
65 let (line, column) = match fragment.split_once([',', ':', 'C']) {
66 Some((line, column)) => (line, Some(column)),
67 None => (fragment, None),
68 };
69 let line = line.parse::<u32>().ok()?.checked_sub(1)?;
70 let column = column
71 .and_then(|column| column.parse::<u32>().ok())
72 .and_then(|column| column.checked_sub(1))
73 .unwrap_or(0);
74 Some((line, column))
75}
76
77#[derive(Debug, Clone)]
79pub struct MarkdownString(pub String);
80
81impl Display for MarkdownString {
82 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
83 write!(f, "{}", self.0)
84 }
85}
86
87pub struct MarkdownEscaped<'a>(pub &'a str);
107
108pub struct MarkdownInlineCode<'a>(pub &'a str);
115
116pub struct MarkdownCodeBlock<'a> {
118 pub tag: &'a str,
119 pub text: &'a str,
120}
121
122impl Display for MarkdownEscaped<'_> {
123 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
124 let mut start_of_unescaped = None;
125 for (ix, c) in self.0.char_indices() {
126 match c {
127 '\\' | '`' | '*' | '_' | '[' | '^' | '$' | '~' | '&' |
129 '#' | '+' | '=' | '-' => {
133 match start_of_unescaped {
134 None => {}
135 Some(start_of_unescaped) => {
136 write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
137 }
138 }
139 write!(formatter, "\\")?;
140 start_of_unescaped = Some(ix);
143 }
144 '<' => {
147 match start_of_unescaped {
148 None => {}
149 Some(start_of_unescaped) => {
150 write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
151 }
152 }
153 write!(formatter, "<")?;
154 start_of_unescaped = None;
155 }
156 '>' => {
159 match start_of_unescaped {
160 None => {}
161 Some(start_of_unescaped) => {
162 write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
163 }
164 }
165 write!(formatter, ">")?;
166 start_of_unescaped = None;
167 }
168 _ => {
169 if start_of_unescaped.is_none() {
170 start_of_unescaped = Some(ix);
171 }
172 }
173 }
174 }
175 if let Some(start_of_unescaped) = start_of_unescaped {
176 write!(formatter, "{}", &self.0[start_of_unescaped..])?;
177 }
178 Ok(())
179 }
180}
181
182impl Display for MarkdownInlineCode<'_> {
183 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
184 let mut all_whitespace = true;
194 let text = self
195 .0
196 .chars()
197 .map(|c| {
198 if c.is_whitespace() {
199 ' '
200 } else {
201 all_whitespace = false;
202 c
203 }
204 })
205 .collect::<String>();
206
207 if all_whitespace {
210 write!(formatter, "`{text}`")
211 } else {
212 let backticks = "`".repeat(count_max_consecutive_chars(&text, '`') + 1);
215 let space = match text.as_bytes() {
216 &[b'`', ..] | &[.., b'`'] => " ", &[b' ', .., b' '] => " ", _ => "", };
220 write!(formatter, "{backticks}{space}{text}{space}{backticks}")
221 }
222 }
223}
224
225impl Display for MarkdownCodeBlock<'_> {
226 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
227 let tag = self.tag;
228 let text = self.text;
229 let backticks = "`".repeat(3.max(count_max_consecutive_chars(text, '`') + 1));
230 write!(formatter, "{backticks}{tag}\n{text}\n{backticks}\n")
231 }
232}
233
234fn count_max_consecutive_chars(text: &str, search: char) -> usize {
238 let mut in_search_chars = false;
239 let mut max_count = 0;
240 let mut cur_count = 0;
241
242 for ch in text.chars() {
243 if ch == search {
244 cur_count += 1;
245 in_search_chars = true;
246 } else if in_search_chars {
247 max_count = max_count.max(cur_count);
248 cur_count = 0;
249 in_search_chars = false;
250 }
251 }
252 max_count.max(cur_count)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn test_markdown_escaped() {
261 let input = r#"
262 # Heading
263
264 Another heading
265 ===
266
267 Another heading variant
268 ---
269
270 Paragraph with [link](https://example.com) and `code`, *emphasis*, and ~strikethrough~.
271
272 ```
273 code block
274 ```
275
276 List with varying leaders:
277 - Item 1
278 * Item 2
279 + Item 3
280
281 Some math: $`\sqrt{3x-1}+(1+x)^2`$
282
283 HTML entity:
284 "#;
285
286 let expected = r#"
287 \# Heading
288
289 Another heading
290 \=\=\=
291
292 Another heading variant
293 \-\-\-
294
295 Paragraph with \[link](https://example.com) and \`code\`, \*emphasis\*, and \~strikethrough\~.
296
297 \`\`\`
298 code block
299 \`\`\`
300
301 List with varying leaders:
302 \- Item 1
303 \* Item 2
304 \+ Item 3
305
306 Some math: \$\`\\sqrt{3x\-1}\+(1\+x)\^2\`\$
307
308 HTML entity: \
309 "#;
310
311 assert_eq!(MarkdownEscaped(input).to_string(), expected);
312 }
313
314 #[test]
315 fn test_markdown_inline_code() {
316 assert_eq!(MarkdownInlineCode(" ").to_string(), "` `");
317 assert_eq!(MarkdownInlineCode("text").to_string(), "`text`");
318 assert_eq!(MarkdownInlineCode("text ").to_string(), "`text `");
319 assert_eq!(MarkdownInlineCode(" text ").to_string(), "` text `");
320 assert_eq!(MarkdownInlineCode("`").to_string(), "`` ` ``");
321 assert_eq!(MarkdownInlineCode("``").to_string(), "``` `` ```");
322 assert_eq!(MarkdownInlineCode("`text`").to_string(), "`` `text` ``");
323 assert_eq!(
324 MarkdownInlineCode("some `text` no leading or trailing backticks").to_string(),
325 "``some `text` no leading or trailing backticks``"
326 );
327 }
328
329 #[test]
330 fn test_count_max_consecutive_chars() {
331 assert_eq!(
332 count_max_consecutive_chars("``a```b``", '`'),
333 3,
334 "the highest seen consecutive segment of backticks counts"
335 );
336 assert_eq!(
337 count_max_consecutive_chars("```a``b`", '`'),
338 3,
339 "it can't be downgraded later"
340 );
341 }
342
343 #[test]
344 fn test_split_local_url_fragment() {
345 assert_eq!(split_local_url_fragment("#heading"), ("", Some("heading")));
346 assert_eq!(
347 split_local_url_fragment("./file.md#heading"),
348 ("./file.md", Some("heading"))
349 );
350 assert_eq!(split_local_url_fragment("./file.md"), ("./file.md", None));
351 assert_eq!(
352 split_local_url_fragment("https://example.com#frag"),
353 ("https://example.com#frag", None)
354 );
355 assert_eq!(
356 split_local_url_fragment("mailto:user@example.com"),
357 ("mailto:user@example.com", None)
358 );
359 assert_eq!(split_local_url_fragment("#"), ("", None));
360 assert_eq!(
361 split_local_url_fragment("../other.md#section"),
362 ("../other.md", Some("section"))
363 );
364 assert_eq!(
365 split_local_url_fragment("123:not-a-scheme#frag"),
366 ("123:not-a-scheme", Some("frag"))
367 );
368 }
369
370 #[test]
371 fn test_source_position_from_fragment() {
372 assert_eq!(source_position_from_fragment("9,16"), Some((8, 15)));
373 assert_eq!(source_position_from_fragment("33,33"), Some((32, 32)));
374 assert_eq!(source_position_from_fragment("L42"), Some((41, 0)));
375 assert_eq!(source_position_from_fragment("L42:7"), Some((41, 6)));
376 assert_eq!(source_position_from_fragment("L3C5"), Some((2, 4)));
377 assert_eq!(source_position_from_fragment("L3C5-L8C10"), Some((2, 4)));
378 assert_eq!(source_position_from_fragment("5"), Some((4, 0)));
379 assert_eq!(source_position_from_fragment("L10-L20"), Some((9, 0)));
380 assert_eq!(source_position_from_fragment("heading"), None);
381 assert_eq!(source_position_from_fragment("0,0"), None);
382 }
383
384 #[test]
385 fn test_generate_heading_slug() {
386 assert_eq!(generate_heading_slug("Hello World"), "hello-world");
387 assert_eq!(generate_heading_slug("Hello World"), "hello--world");
388 assert_eq!(generate_heading_slug("Hello-World"), "hello-world");
389 assert_eq!(
390 generate_heading_slug("Some **bold** text"),
391 "some-bold-text"
392 );
393 assert_eq!(generate_heading_slug("Let's try with Ü"), "lets-try-with-ü");
394 assert_eq!(
395 generate_heading_slug("heading with 123 numbers"),
396 "heading-with-123-numbers"
397 );
398 assert_eq!(
399 generate_heading_slug("What about (parens)?"),
400 "what-about-parens"
401 );
402 assert_eq!(
403 generate_heading_slug(" leading spaces "),
404 "leading-spaces"
405 );
406 }
407}