use super::model::Inline;
pub(crate) const MAX_NESTING: usize = 16;
pub(crate) const BLOCK_WEIGHT: usize = 2;
pub(crate) const SNIFF_BYTES: usize = 64 * 1024;
pub(crate) fn normalize(source: &str) -> String {
let source = source.strip_prefix('\u{feff}').unwrap_or(source);
let mut out = String::with_capacity(source.len());
let mut chars = source.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
out.push('\n');
}
'\0' => out.push('\u{fffd}'),
'\n' | '\t' => out.push(ch),
'\u{1}'..='\u{1f}' | '\u{7f}'..='\u{9f}' => {
out.push_str(&crate::sanitize_terminal_controls(
ch.encode_utf8(&mut [0; 4]),
));
}
_ => out.push(ch),
}
}
out
}
pub(crate) fn sample(text: &str) -> &str {
if text.len() <= SNIFF_BYTES {
return text;
}
let mut end = SNIFF_BYTES;
while !text.is_char_boundary(end) {
end -= 1;
}
&text[..end]
}
pub(crate) fn prefix(text: &str, max: usize) -> &str {
if text.len() <= max {
return text;
}
let mut end = max;
while !text.is_char_boundary(end) {
end -= 1;
}
&text[..end]
}
pub(crate) fn expand_tabs(code: &str) -> String {
if !code.contains('\t') {
return code.to_string();
}
let mut out = String::with_capacity(code.len());
let mut column = 0;
for ch in code.chars() {
match ch {
'\t' => {
let spaces = 4 - column % 4;
out.extend(std::iter::repeat_n(' ', spaces));
column += spaces;
}
'\n' | '\r' => {
out.push(ch);
column = 0;
}
_ => {
out.push(ch);
column += 1;
}
}
}
out
}
pub(crate) fn indent_of(line: &str) -> usize {
let mut column = 0;
for ch in line.chars() {
match ch {
' ' => column += 1,
'\t' => column = (column / 8 + 1) * 8,
_ => break,
}
}
column
}
pub(crate) fn dedent(line: &str, columns: usize) -> String {
let mut column = 0;
for (index, ch) in line.char_indices() {
if column >= columns {
let pad = column - columns;
return format!("{}{}", " ".repeat(pad), &line[index..]);
}
match ch {
' ' => column += 1,
'\t' => column = (column / 8 + 1) * 8,
_ => {
return format!(
"{}{}",
" ".repeat(column.saturating_sub(columns)),
&line[index..]
);
}
}
}
String::new()
}
pub(crate) struct Positions(Vec<usize>);
impl Positions {
pub(crate) fn of(text: &str, pattern: &str) -> Self {
Positions(text.match_indices(pattern).map(|(at, _)| at).collect())
}
pub(crate) fn from_sorted(positions: Vec<usize>) -> Self {
Positions(positions)
}
pub(crate) fn at_or_after(&self, position: usize) -> Option<usize> {
let index = self.0.partition_point(|&at| at < position);
self.0.get(index).copied()
}
}
pub(crate) fn char_before(text: &str, at: usize) -> Option<char> {
text[..at].chars().next_back()
}
pub(crate) fn char_at(text: &str, at: usize) -> Option<char> {
text[at..].chars().next()
}
const SCHEMES: &[&str] = &[
"https://", "http://", "ftp://", "ftps://", "sftp://", "file://", "mailto:", "irc://",
"ircs://", "news:", "ssh://", "git://",
];
pub(crate) fn url_end(text: &str, at: usize) -> Option<usize> {
let first = text.as_bytes().get(at)?.to_ascii_lowercase();
if !matches!(first, b'h' | b'f' | b's' | b'm' | b'i' | b'n' | b'g') {
return None;
}
let rest = &text[at..];
let scheme = SCHEMES
.iter()
.find(|scheme| starts_with_ignore_case(rest, scheme))?;
if char_before(text, at).is_some_and(|c| c.is_alphanumeric() || c == '/' || c == '.') {
return None;
}
let mut end = at + scheme.len();
for (offset, ch) in text[end..].char_indices() {
let bracket = ch == '[' && offset > 0;
if ch.is_whitespace() || bracket || matches!(ch, '<' | '>' | '"' | '`' | '\u{0}') {
end += offset;
return trim_url(text, at, end, scheme.len());
}
}
trim_url(text, at, text.len(), scheme.len())
}
fn trim_url(text: &str, at: usize, mut end: usize, scheme: usize) -> Option<usize> {
while let Some(last) = char_before(text, end) {
let unbalanced_close =
last == ')' && text[at..end].matches('(').count() < text[at..end].matches(')').count();
let unbalanced_bracket =
last == ']' && text[at..end].matches('[').count() < text[at..end].matches(']').count();
if matches!(
last,
'.' | ',' | ';' | ':' | '!' | '?' | '\'' | '*' | '_' | '~'
) || unbalanced_close
|| unbalanced_bracket
{
end -= last.len_utf8();
} else {
break;
}
}
(end > at + scheme).then_some(end)
}
fn starts_with_ignore_case(text: &str, prefix: &str) -> bool {
text.len() >= prefix.len()
&& text.is_char_boundary(prefix.len())
&& text[..prefix.len()].eq_ignore_ascii_case(prefix)
}
#[derive(Default)]
pub(crate) struct InlineBuf {
pub(crate) inlines: Vec<Inline>,
text: String,
}
impl InlineBuf {
pub(crate) fn push_str(&mut self, text: &str) {
self.text.push_str(text);
}
pub(crate) fn push(&mut self, ch: char) {
self.text.push(ch);
}
pub(crate) fn inline(&mut self, inline: Inline) {
self.flush();
self.inlines.push(inline);
}
fn flush(&mut self) {
if !self.text.is_empty() {
let text = std::mem::take(&mut self.text);
match self.inlines.last_mut() {
Some(Inline::Text(last)) => last.push_str(&text),
_ => self.inlines.push(Inline::Text(text)),
}
}
}
pub(crate) fn finish(mut self) -> Vec<Inline> {
self.flush();
self.inlines
}
}
pub(crate) fn join_lines<'a>(lines: impl IntoIterator<Item = &'a str>) -> String {
let mut out = String::new();
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
}
out
}
pub(crate) fn trim_inlines(mut inlines: Vec<Inline>) -> Vec<Inline> {
if let Some(Inline::Text(first)) = inlines.first_mut() {
let trimmed = first.trim_start().to_string();
*first = trimmed;
}
if let Some(Inline::Text(last)) = inlines.last_mut() {
let trimmed = last.trim_end().to_string();
*last = trimmed;
}
inlines.retain(|inline| !matches!(inline, Inline::Text(text) if text.is_empty()));
inlines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_end_before_trailing_punctuation() {
let text = "see https://example.com/a_(b), then.";
let start = text.find("https").unwrap();
let end = url_end(text, start).unwrap();
assert_eq!(&text[start..end], "https://example.com/a_(b)");
let text = "(https://example.com/x).";
let start = text.find("https").unwrap();
assert_eq!(
&text[start..url_end(text, start).unwrap()],
"https://example.com/x"
);
assert_eq!(url_end("xhttps://a.b", 1), None);
assert_eq!(url_end("https://", 0), None);
}
#[test]
fn indentation_counts_tabs_to_eight() {
assert_eq!(indent_of("\t- a"), 8);
assert_eq!(indent_of(" \t- a"), 8);
assert_eq!(dedent("\t- a", 2), " - a");
assert_eq!(dedent(" x", 2), " x");
}
#[test]
fn line_endings_are_normalised() {
assert_eq!(normalize("\u{feff}a\r\nb\rc\0"), "a\nb\nc\u{fffd}");
assert_eq!(
normalize("a\tb\x1b[2J\u{9b}c\x07\n"),
"a\tb\u{241b}[2J\\u{009B}c\u{2407}\n"
);
}
}