use super::*;
pub(super) fn settle_mentions(text: &mut Text) {
let settled: Vec<usize> = (0..text.marks.len())
.filter(|ix| {
matches!(
text.marks[*ix].mark,
Mark::Mention {
form: Form::Auto,
..
}
) && !is_shorthand(text, *ix)
})
.collect();
for ix in settled {
if let Mark::Mention { form, .. } = &mut text.marks[ix].mark {
*form = Form::Chip;
}
}
}
pub(crate) fn is_shorthand(text: &Text, ix: usize) -> bool {
let span = &text.marks[ix];
let Mark::Mention { url, form } = &span.mark else {
return false;
};
*form == Form::Auto
&& text.text.get(span.range.clone()) == Some(url.as_str())
&& is_link(url)
&& text.alone(ix)
}
pub(super) const SCHEMES: [&str; 2] = ["https://", "http://"];
pub(crate) fn urls(text: &str) -> Vec<Range<usize>> {
let mut found = Vec::new();
let mut at = 0;
while at < text.len() {
let Some((start, scheme)) = SCHEMES
.iter()
.filter_map(|scheme| text[at..].find(scheme).map(|ix| (at + ix, *scheme)))
.min_by_key(|(ix, _)| *ix)
else {
break;
};
let stop = text[start..]
.find(char::is_whitespace)
.map_or(text.len(), |ix| start + ix);
let end = start + trim_url(&text[start..stop]);
let opens = text[..start]
.chars()
.next_back()
.is_none_or(|c| !c.is_alphanumeric());
if opens && end > start + scheme.len() {
found.push(start..end);
}
at = stop.max(start + 1);
}
found
}
pub fn is_link(source: &str) -> bool {
let Some((scheme, rest)) = source.split_once("://") else {
return false;
};
let mut chars = scheme.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
&& !rest.is_empty()
&& !source.contains(char::is_whitespace)
}
pub fn is_url(source: &str) -> bool {
matches!(urls(source).as_slice(), [only] if *only == (0..source.len()))
}
pub fn is_image(source: &str) -> bool {
let path = source.split(['?', '#']).next().unwrap_or(source);
let Some((_, extension)) = path.rsplit_once('.') else {
return false;
};
matches!(
extension.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tif" | "tiff" | "avif"
)
}
pub(super) fn trim_url(run: &str) -> usize {
let mut end = run.len();
while let Some(last) = run[..end].chars().next_back() {
let keep = match last {
'.' | ',' | ';' | ':' | '!' | '?' | '\'' | '"' => false,
')' => run[..end].matches('(').count() >= run[..end].matches(')').count(),
']' => run[..end].matches('[').count() >= run[..end].matches(']').count(),
_ => true,
};
if keep {
break;
}
end -= last.len_utf8();
}
end
}
pub(super) fn linkify(text: &mut Text) {
let fresh: Vec<Range<usize>> = urls(&text.text)
.into_iter()
.filter(|range| {
!text.marks.iter().any(|span| {
matches!(
span.mark,
Mark::Link(_) | Mark::Mention { .. } | Mark::Image(_) | Mark::Code
) && span.range.start < range.end
&& range.start < span.range.end
})
})
.collect();
for range in fresh {
let url = text.text[range.clone()].to_string();
text.marks.push(MarkSpan {
range,
mark: Mark::Link(url),
});
}
}
pub(crate) fn normalize(text: &str, marks: &[MarkSpan]) -> Text {
let bytes = text.as_bytes();
let mut keep = vec![true; text.len()];
let mut line_begin = 0;
for offset in memchr_newlines(text).chain([text.len()]) {
let line = &text[line_begin..offset];
let lead = line.len() - line.trim_start_matches([' ', '\t']).len();
let trail = line.len() - line.trim_end_matches([' ', '\t']).len();
keep[line_begin..line_begin + lead].fill(false);
keep[offset - trail..offset].fill(false);
line_begin = offset + 1;
}
let mut head = 0;
while head < text.len() && (!keep[head] || bytes[head] == b'\n') {
keep[head] = false;
head += 1;
}
let mut tail = text.len();
while tail > 0 && (!keep[tail - 1] || bytes[tail - 1] == b'\n') {
keep[tail - 1] = false;
tail -= 1;
}
let mut out = String::with_capacity(text.len());
let mut map = vec![0; text.len() + 1];
for (offset, ch) in text.char_indices() {
map[offset] = out.len();
if keep[offset] {
out.push(ch);
}
}
map[text.len()] = out.len();
let marks = marks
.iter()
.map(|span| MarkSpan {
range: map[span.range.start]..map[span.range.end],
mark: span.mark.clone(),
})
.filter(|span| !span.range.is_empty() || matches!(span.mark, Mark::Image(_)))
.collect();
Text {
text: out,
marks: merge_same_mark(marks),
}
}
pub(super) fn merge_same_mark(mut marks: Vec<MarkSpan>) -> Vec<MarkSpan> {
let mut ix = 0;
while ix < marks.len() {
let mut fused = None;
for other in ix + 1..marks.len() {
let (a, b) = (&marks[ix], &marks[other]);
if a.mark == b.mark
&& !matches!(a.mark, Mark::Image(_) | Mark::Mention { .. })
&& a.range.start <= b.range.end
&& b.range.start <= a.range.end
{
fused = Some((
other,
a.range.start.min(b.range.start),
a.range.end.max(b.range.end),
));
break;
}
}
match fused {
Some((other, start, end)) => {
marks[ix].range = start..end;
marks.remove(other);
}
None => ix += 1,
}
}
marks
}
pub(crate) fn collapse_to_one_line(text: &mut Text) {
if text.text.contains('\n') {
text.text = text.text.replace('\n', " ");
}
}
pub(super) fn split_width(alt: &str) -> (&str, Option<u32>) {
let Some((caption, tail)) = alt.rsplit_once('|') else {
return (alt, None);
};
match tail.parse().ok().filter(|width| *width > 0) {
Some(width) => (caption, Some(width)),
None => (alt, None),
}
}
pub(super) fn memchr_newlines(text: &str) -> impl Iterator<Item = usize> + '_ {
text.bytes()
.enumerate()
.filter_map(|(ix, b)| (b == b'\n').then_some(ix))
}