use std::fmt::{Display, Formatter};
pub fn generate_heading_slug(text: &str) -> String {
text.trim()
.chars()
.filter_map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
Some(c.to_lowercase().next().unwrap_or(c))
} else if c == ' ' {
Some('-')
} else {
None
}
})
.collect()
}
fn has_uri_scheme(url: &str) -> bool {
let mut chars = url.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
for c in chars {
if c == ':' {
return true;
}
if !(c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
return false;
}
}
false
}
pub fn split_local_url_fragment(url: &str) -> (&str, Option<&str>) {
if has_uri_scheme(url) {
return (url, None);
}
match url.find('#') {
Some(pos) => {
let path = &url[..pos];
let fragment = &url[pos + 1..];
(
path,
if fragment.is_empty() {
None
} else {
Some(fragment)
},
)
}
None => (url, None),
}
}
#[derive(Debug, Clone)]
pub struct MarkdownString(pub String);
impl Display for MarkdownString {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
pub struct MarkdownEscaped<'a>(pub &'a str);
pub struct MarkdownInlineCode<'a>(pub &'a str);
pub struct MarkdownCodeBlock<'a> {
pub tag: &'a str,
pub text: &'a str,
}
impl Display for MarkdownEscaped<'_> {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
let mut start_of_unescaped = None;
for (ix, c) in self.0.char_indices() {
match c {
'\\' | '`' | '*' | '_' | '[' | '^' | '$' | '~' | '&' |
'#' | '+' | '=' | '-' => {
match start_of_unescaped {
None => {}
Some(start_of_unescaped) => {
write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
}
}
write!(formatter, "\\")?;
start_of_unescaped = Some(ix);
}
'<' => {
match start_of_unescaped {
None => {}
Some(start_of_unescaped) => {
write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
}
}
write!(formatter, "<")?;
start_of_unescaped = None;
}
'>' => {
match start_of_unescaped {
None => {}
Some(start_of_unescaped) => {
write!(formatter, "{}", &self.0[start_of_unescaped..ix])?;
}
}
write!(formatter, ">")?;
start_of_unescaped = None;
}
_ => {
if start_of_unescaped.is_none() {
start_of_unescaped = Some(ix);
}
}
}
}
if let Some(start_of_unescaped) = start_of_unescaped {
write!(formatter, "{}", &self.0[start_of_unescaped..])?;
}
Ok(())
}
}
impl Display for MarkdownInlineCode<'_> {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
let mut all_whitespace = true;
let text = self
.0
.chars()
.map(|c| {
if c.is_whitespace() {
' '
} else {
all_whitespace = false;
c
}
})
.collect::<String>();
if all_whitespace {
write!(formatter, "`{text}`")
} else {
let backticks = "`".repeat(count_max_consecutive_chars(&text, '`') + 1);
let space = match text.as_bytes() {
&[b'`', ..] | &[.., b'`'] => " ", &[b' ', .., b' '] => " ", _ => "", };
write!(formatter, "{backticks}{space}{text}{space}{backticks}")
}
}
}
impl Display for MarkdownCodeBlock<'_> {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
let tag = self.tag;
let text = self.text;
let backticks = "`".repeat(3.max(count_max_consecutive_chars(text, '`') + 1));
write!(formatter, "{backticks}{tag}\n{text}\n{backticks}\n")
}
}
fn count_max_consecutive_chars(text: &str, search: char) -> usize {
let mut in_search_chars = false;
let mut max_count = 0;
let mut cur_count = 0;
for ch in text.chars() {
if ch == search {
cur_count += 1;
in_search_chars = true;
} else if in_search_chars {
max_count = max_count.max(cur_count);
cur_count = 0;
in_search_chars = false;
}
}
max_count.max(cur_count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_markdown_escaped() {
let input = r#"
# Heading
Another heading
===
Another heading variant
---
Paragraph with [link](https://example.com) and `code`, *emphasis*, and ~strikethrough~.
```
code block
```
List with varying leaders:
- Item 1
* Item 2
+ Item 3
Some math: $`\sqrt{3x-1}+(1+x)^2`$
HTML entity:
"#;
let expected = r#"
\# Heading
Another heading
\=\=\=
Another heading variant
\-\-\-
Paragraph with \[link](https://example.com) and \`code\`, \*emphasis\*, and \~strikethrough\~.
\`\`\`
code block
\`\`\`
List with varying leaders:
\- Item 1
\* Item 2
\+ Item 3
Some math: \$\`\\sqrt{3x\-1}\+(1\+x)\^2\`\$
HTML entity: \
"#;
assert_eq!(MarkdownEscaped(input).to_string(), expected);
}
#[test]
fn test_markdown_inline_code() {
assert_eq!(MarkdownInlineCode(" ").to_string(), "` `");
assert_eq!(MarkdownInlineCode("text").to_string(), "`text`");
assert_eq!(MarkdownInlineCode("text ").to_string(), "`text `");
assert_eq!(MarkdownInlineCode(" text ").to_string(), "` text `");
assert_eq!(MarkdownInlineCode("`").to_string(), "`` ` ``");
assert_eq!(MarkdownInlineCode("``").to_string(), "``` `` ```");
assert_eq!(MarkdownInlineCode("`text`").to_string(), "`` `text` ``");
assert_eq!(
MarkdownInlineCode("some `text` no leading or trailing backticks").to_string(),
"``some `text` no leading or trailing backticks``"
);
}
#[test]
fn test_count_max_consecutive_chars() {
assert_eq!(
count_max_consecutive_chars("``a```b``", '`'),
3,
"the highest seen consecutive segment of backticks counts"
);
assert_eq!(
count_max_consecutive_chars("```a``b`", '`'),
3,
"it can't be downgraded later"
);
}
#[test]
fn test_split_local_url_fragment() {
assert_eq!(split_local_url_fragment("#heading"), ("", Some("heading")));
assert_eq!(
split_local_url_fragment("./file.md#heading"),
("./file.md", Some("heading"))
);
assert_eq!(split_local_url_fragment("./file.md"), ("./file.md", None));
assert_eq!(
split_local_url_fragment("https://example.com#frag"),
("https://example.com#frag", None)
);
assert_eq!(
split_local_url_fragment("mailto:user@example.com"),
("mailto:user@example.com", None)
);
assert_eq!(split_local_url_fragment("#"), ("", None));
assert_eq!(
split_local_url_fragment("../other.md#section"),
("../other.md", Some("section"))
);
assert_eq!(
split_local_url_fragment("123:not-a-scheme#frag"),
("123:not-a-scheme", Some("frag"))
);
}
#[test]
fn test_generate_heading_slug() {
assert_eq!(generate_heading_slug("Hello World"), "hello-world");
assert_eq!(generate_heading_slug("Hello World"), "hello--world");
assert_eq!(generate_heading_slug("Hello-World"), "hello-world");
assert_eq!(
generate_heading_slug("Some **bold** text"),
"some-bold-text"
);
assert_eq!(generate_heading_slug("Let's try with Ü"), "lets-try-with-ü");
assert_eq!(
generate_heading_slug("heading with 123 numbers"),
"heading-with-123-numbers"
);
assert_eq!(
generate_heading_slug("What about (parens)?"),
"what-about-parens"
);
assert_eq!(
generate_heading_slug(" leading spaces "),
"leading-spaces"
);
}
}