use memchr::memchr3;
use super::constants::{ALWAYS_ESCAPED, LINE_START_ESCAPED};
use super::ctx::FormatOpts;
#[allow(clippy::cast_possible_truncation)]
pub(super) fn push_normalized_text(text: &mut String, new_text: &str, f_opts: FormatOpts) {
if !text.ends_with(['\n', ' ']) && !new_text.is_empty() && new_text.trim_ascii().is_empty() {
text.push(' ');
return;
}
let follows_newline = text.ends_with(['\n', ' ']) || text.is_empty();
let push_start_whitespace = (f_opts.inline || !follows_newline)
&& new_text.starts_with(|c: char| c.is_ascii_whitespace());
let push_end_whitespace = new_text.ends_with(|c: char| c.is_ascii_whitespace());
let escape = !f_opts.skip_escape;
let mut iter = new_text.split_ascii_whitespace();
let Some(first) = iter.next() else { return };
let is_line_start = text.is_empty() || text.ends_with('\n') || f_opts.list_item;
if push_start_whitespace {
text.push(' ');
} else if escape && matches!(first, "." | ")") && ends_with_marker_digits(text) {
text.push('\\');
}
push_escaped_chunk(text, first, escape, is_line_start);
for word in iter {
text.push(' ');
push_escaped_chunk(text, word, escape, false);
}
if push_end_whitespace && !text.ends_with(|c: char| c.is_ascii_whitespace()) {
text.push(' ');
}
}
fn ends_with_marker_digits(text: &str) -> bool {
let stem = text.trim_end_matches(|c: char| c.is_ascii_digit());
if !(1..=9).contains(&(text.len() - stem.len())) {
return false;
}
let stem = stem.strip_suffix(' ').map_or(stem, |s| {
let word = s.rsplit([' ', '\n']).next().unwrap_or(s);
if matches!(word, "-" | "+") || is_ordered_list_marker(word) {
&s[..s.len() - word.len()]
} else {
stem
}
});
let indent = stem.trim_end_matches(' ');
indent.is_empty() || indent.ends_with('\n')
}
fn is_ordered_list_marker(chunk: &str) -> bool {
let Some(stem) = chunk.strip_suffix(['.', ')']) else {
return false;
};
!stem.is_empty() && stem.as_bytes().iter().all(u8::is_ascii_digit)
}
pub(super) fn push_escaped_chunk(text: &mut String, chunk: &str, escape: bool, line_start: bool) {
if !escape {
text.push_str(chunk);
return;
}
let list_marker = line_start && is_ordered_list_marker(chunk);
let bytes = chunk.as_bytes();
let setext_underline = line_start && bytes.iter().all(|&b| b == b'=');
let mut last = 0;
text.reserve(chunk.len());
for (i, &b) in bytes.iter().enumerate() {
let escaped = if ALWAYS_ESCAPED.contains(&b) {
true
} else if LINE_START_ESCAPED.contains(&b) {
line_start && i == 0
} else if b == b'!' {
bytes.get(i + 1) == Some(&b'[')
} else if b == b'.' || b == b')' {
list_marker
} else if b == b'=' {
setext_underline && i == 0
} else {
false
};
if escaped {
text.push_str(&chunk[last..i]);
text.push('\\');
last = i;
}
}
text.push_str(&chunk[last..]);
}
pub(super) fn escape_trailing_bang(text: &mut String) {
if text.ends_with('!') {
text.pop();
text.push_str("\\!");
}
}
pub(super) fn trim_trailing_space(s: &mut String) {
s.truncate(s.trim_end_matches(' ').len());
}
pub(super) fn add_linebreaks(text: &mut String, linebreak: &str, end: &str) {
trim_trailing_space(text);
while !text.ends_with(end) {
text.push_str(linebreak);
}
}
pub(super) fn push_md_url(text: &mut String, dest: &str) {
let mut balance: i32 = 0;
let mut needs_wrap = false;
let mut has_backslash = false;
let dest = &html_escape(dest);
for &b in dest.as_bytes() {
match b {
b'(' => balance += 1,
b')' => {
balance -= 1;
if balance < 0 {
needs_wrap = true;
break;
}
}
b' ' | b'<' | 0..=31 | 127 => {
needs_wrap = true;
break;
}
b'\\' => has_backslash = true,
_ => {}
}
}
if needs_wrap || balance != 0 {
text.reserve(dest.len() + 4);
text.push('<');
for c in dest.chars() {
match c {
'\\' => text.push_str("\\\\"),
'<' => text.push_str("\\<"),
'>' => text.push_str("\\>"),
'\n' => text.push_str("%0A"),
'\r' => text.push_str("%0D"),
c => text.push(c),
}
}
text.push('>');
} else if has_backslash {
text.push_str(&dest.replace('\\', "\\\\"));
} else {
text.push_str(dest);
}
}
pub(super) fn push_title(text: &mut String, title: &str) {
text.push_str(" \"");
if title.contains('"') {
let mut normalized = String::with_capacity(title.len());
push_normalized_text(&mut normalized, title, FormatOpts::new());
text.push_str(&normalized.replace('"', "\\\""));
} else {
push_normalized_text(text, title, FormatOpts::new());
}
text.push('"');
}
pub(super) fn push_block_space(text: &mut String) {
if !text.is_empty() && !text.ends_with(|c: char| c.is_ascii_whitespace()) {
text.push(' ');
}
}
pub(super) fn trim_space(s: &mut String) {
s.truncate(s.trim_ascii_end().len());
s.drain(..s.len() - s.trim_ascii_start().len());
}
pub(super) const fn linebreak(br: bool) -> &'static str {
if br { "<br>" } else { "\n" }
}
pub(super) const fn double_linebreak(br: bool) -> &'static str {
if br { "<br><br>" } else { "\n\n" }
}
pub fn html_escape(text: &str) -> String {
let bytes = text.as_bytes();
let mut result = String::with_capacity(text.len());
let mut search_start = 0;
while search_start < bytes.len() {
let next_special = if let Some(i) = memchr3(b'"', b'&', 0xC2, &bytes[search_start..]) {
search_start + i
} else {
result.push_str(&text[search_start..]);
break;
};
result.push_str(&text[search_start..next_special]);
let (replacement, advance) = match bytes[next_special] {
b'&' => ("&", 1),
b'"' => (""", 1),
0xC2 if bytes.get(next_special + 1) == Some(&0xA0) => (" ", 2),
_ => {
result.push_str(&text[next_special..next_special + 2]);
search_start = next_special + 2;
continue;
}
};
result.push_str(replacement);
search_start = next_special + advance;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_text() {
let t = r"Some text: x `y` *z* _w_ [v] <u> #h >q -l +m !i . .5 5. |q|";
let mut text = String::new();
push_normalized_text(&mut text, t, FormatOpts::new());
assert_eq!(
text,
r"Some text: x \`y\` \*z\* \_w\_ \[v\] \<u> #h >q -l +m !i . .5 5. \|q\|"
);
let mut text = String::new();
for word in ["#h", ">q", "-l", "+m", "2024.", "5)", "."] {
push_escaped_chunk(&mut text, word, true, true);
text.push(' ');
}
text.pop();
assert_eq!(text, r"\#h \>q \-l \+m 2024\. 5\) .");
let mut text = String::new();
push_normalized_text(&mut text, " # x", FormatOpts::new().inline());
assert_eq!(text, r" \# x");
let mut text = String::new();
push_normalized_text(&mut text, t, FormatOpts::new().skip_escape());
assert_eq!(
text,
r"Some text: x `y` *z* _w_ [v] <u> #h >q -l +m !i . .5 5. |q|"
);
}
#[test]
fn test_html_escape() {
struct TestCase {
name: &'static str,
input: &'static str,
expected: &'static str,
}
let test_cases = [
TestCase {
name: "empty string",
input: "",
expected: "",
},
TestCase {
name: "no special chars",
input: "hello_world_123",
expected: "hello_world_123",
},
TestCase {
name: "angle brackets untouched",
input: "a < b and c > d",
expected: "a < b and c > d",
},
TestCase {
name: "quotes escaped",
input: "Width: \"100%\"",
expected: "Width: "100%"",
},
TestCase {
name: "ampersand escaped",
input: "Tom & Jerry",
expected: "Tom & Jerry",
},
TestCase {
name: "non-breaking space",
input: "Hello\u{00A0}World",
expected: "Hello World",
},
TestCase {
name: "mixed attribute content",
input: "class=\"btn & primary\" title=\"1 < 2 & 3 > 2\"",
expected: "class="btn & primary" title="1 < 2 & 3 > 2"",
},
TestCase {
name: "c2 byte without nbsp",
input: "Test\u{0080}Value",
expected: "Test\u{0080}Value",
},
TestCase {
name: "amp in url",
input: "/?q=©",
expected: "/?q=&copy;",
},
TestCase {
name: "amp in url",
input: "/?a=1&b=2",
expected: "/?a=1&b=2",
},
];
for tc in test_cases {
let actual = html_escape(tc.input);
assert_eq!(
actual, tc.expected,
"Failed test case: '{}' (input: {:?})",
tc.name, tc.input
);
}
}
}