use super::*;
pub(super) fn is_link_span(span: &Span<'static>) -> bool {
use ratatui::style::{Color, Modifier};
span.style.add_modifier.contains(Modifier::UNDERLINED) && span.style.fg == Some(Color::Blue)
}
pub(super) fn github_slug(text: &str) -> String {
let mut s = String::new();
for c in text.trim().chars() {
if c.is_alphanumeric() {
s.extend(c.to_lowercase());
} else if c == ' ' {
s.push('-');
} else if c == '-' || c == '_' {
s.push(c);
}
}
s
}
pub(super) fn compute_md_anchors(lines: &[Line<'static>]) -> Vec<(String, usize)> {
use std::collections::HashMap;
let mut anchors = Vec::new();
let mut counts: HashMap<String, usize> = HashMap::new();
for (i, line) in lines.iter().enumerate() {
let Some(text) = crate::preview::markdown::heading_text(line) else {
continue;
};
let base = github_slug(&text);
if base.is_empty() {
continue;
}
let n = counts.entry(base.clone()).or_insert(0);
let slug = if *n == 0 {
base.clone()
} else {
format!("{base}-{n}")
};
*n += 1;
anchors.push((slug, i));
}
anchors
}
pub(super) fn build_md_items(
lines: &[Line<'static>],
targets: &[String],
fence_ords: &[usize],
) -> Vec<MdItem> {
let mut items = Vec::new();
let mut k = 0usize;
for (li, line) in lines.iter().enumerate() {
for span in &line.spans {
if is_link_span(span) {
let target = targets.get(k).cloned().unwrap_or_default();
items.push(MdItem {
line: li,
kind: MdItemKind::Link { target },
});
k += 1;
} else if crate::preview::markdown::is_task_span(span) {
if let Some(state) =
crate::preview::markdown::task_span_state(span.content.as_ref())
{
items.push(MdItem {
line: li,
kind: MdItemKind::Task { state },
});
}
} else if crate::preview::markdown::is_code_header_span(span) {
items.push(MdItem {
line: li,
kind: MdItemKind::CodeBlock,
});
} else if crate::preview::markdown::is_mermaid_header_span(span) {
let seen = items
.iter()
.filter(|it| matches!(it.kind, MdItemKind::MermaidFence { .. }))
.count();
let ordinal = fence_ords.get(seen).copied().unwrap_or(seen);
items.push(MdItem {
line: li,
kind: MdItemKind::MermaidFence { ordinal },
});
} else if crate::preview::markdown::is_details_header_span(span) {
let ordinal = items
.iter()
.filter(|it| matches!(it.kind, MdItemKind::Details { .. }))
.count();
items.push(MdItem {
line: li,
kind: MdItemKind::Details { ordinal },
});
}
}
}
items
}
pub(super) fn invert_focused_line(
line: &Line<'static>,
ordinal: usize,
whole_line: bool,
) -> Line<'static> {
use ratatui::style::Modifier;
let style = line.style;
let mut seen = 0usize;
let spans = line
.spans
.iter()
.cloned()
.map(|mut span| {
if whole_line {
span.style = span.style.add_modifier(Modifier::REVERSED);
} else if is_link_span(&span)
|| crate::preview::markdown::is_task_span(&span)
|| crate::preview::markdown::is_code_header_span(&span)
|| crate::preview::markdown::is_mermaid_header_span(&span)
|| crate::preview::markdown::is_details_header_span(&span)
{
if seen == ordinal {
span.style = span.style.add_modifier(Modifier::REVERSED);
}
seen += 1;
}
span
})
.collect::<Vec<_>>();
Line::from(spans).style(style)
}
pub(super) fn collapse_links(
lines: Vec<Line<'static>>,
icons: bool,
) -> (Vec<Line<'static>>, Vec<String>) {
use ratatui::style::{Color, Modifier, Style};
let link_style = Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED);
let mut out = Vec::with_capacity(lines.len());
let mut targets = Vec::new();
for line in lines {
let style = line.style;
let spans = line.spans;
let n = spans.len();
let mut new: Vec<Span<'static>> = Vec::with_capacity(n);
let mut i = 0;
while i < n {
if i + 1 < n
&& is_link_span(&spans[i])
&& crate::preview::markdown::is_hidden_link_target(&spans[i + 1])
{
targets.push(spans[i + 1].content.to_string());
new.push(spans[i].clone());
i += 2;
continue;
}
let is_link_pattern = i + 2 < n
&& spans[i + 1].content.as_ref() == " ("
&& is_link_span(&spans[i + 2])
&& spans.get(i + 3).is_some_and(|s| s.content.starts_with(')'));
if is_link_pattern {
let label = spans[i].content.as_ref();
targets.push(spans[i + 2].content.to_string());
let text = if icons {
format!("{} {label}", crate::ui::icons::link_icon())
} else {
label.to_string()
};
new.push(Span::styled(text, link_style));
let closer = spans[i + 3].content.as_ref();
if closer.len() > 1 {
new.push(Span::styled(closer[1..].to_string(), spans[i + 3].style));
}
i += 4;
} else {
new.push(spans[i].clone());
i += 1;
}
}
out.push(Line::from(new).style(style));
}
(out, targets)
}
fn trim_url_end(s: &str) -> &str {
let mut end = s.len();
loop {
let sub = &s[..end];
let Some(last) = sub.chars().last() else {
break;
};
if "?!.,:*_~'\"".contains(last) {
end -= last.len_utf8();
continue;
}
if last == ')' && sub.matches(')').count() > sub.matches('(').count() {
end -= 1;
continue;
}
break;
}
&s[..end]
}
fn is_url_stop(c: char) -> bool {
c.is_whitespace()
|| c == '<'
|| matches!(
c,
'、' | '。'
| ','
| '.'
| '!'
| '?'
| ';'
| ':'
| '('
| ')'
| '「'
| '」'
| '『'
| '』'
| '【'
| '】'
| '〈'
| '〉'
| '《'
| '》'
| '…'
| '・'
| '〜'
| '”'
| '“'
)
}
fn match_bare_url(rest: &str) -> Option<(usize, String)> {
let is_www = rest.starts_with("www.");
if !(is_www || rest.starts_with("http://") || rest.starts_with("https://")) {
return None;
}
let run_end = rest.find(is_url_stop).unwrap_or(rest.len());
let run = trim_url_end(&rest[..run_end]);
let valid = if is_www {
run.len() > 4 && run[4..].contains(|c: char| c.is_ascii_alphanumeric())
} else {
run.find("://").is_some_and(|p| run.len() > p + 3)
};
if !valid {
return None;
}
let target = if is_www {
format!("http://{run}")
} else {
run.to_string()
};
Some((run.len(), target))
}
fn is_email_local(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
}
fn is_email_domain(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '-')
}
fn match_bare_email(rest: &str) -> Option<(usize, String)> {
let local_len: usize = rest
.chars()
.take_while(|&c| is_email_local(c))
.map(char::len_utf8)
.sum();
if local_len == 0 || !rest[local_len..].starts_with('@') {
return None;
}
let after_at = &rest[local_len + 1..];
let mut dom_len: usize = after_at
.chars()
.take_while(|&c| is_email_domain(c))
.map(char::len_utf8)
.sum();
while dom_len > 0 && matches!(after_at.as_bytes()[dom_len - 1], b'.' | b'-') {
dom_len -= 1;
}
let domain = &after_at[..dom_len];
let tld_ok = domain
.rsplit_once('.')
.is_some_and(|(_, tld)| tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()));
if !tld_ok {
return None;
}
let end = local_len + 1 + dom_len;
Some((end, format!("mailto:{}", &rest[..end])))
}
pub(super) fn find_bare_links(text: &str) -> Vec<(usize, usize, String)> {
let bytes = text.as_bytes();
let n = bytes.len();
let mut out: Vec<(usize, usize, String)> = Vec::new();
let mut i = 0usize;
while i < n {
let boundary_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
if boundary_ok {
let rest = &text[i..];
if let Some((len, target)) = match_bare_url(rest).or_else(|| match_bare_email(rest)) {
out.push((i, i + len, target));
i += len.max(1);
continue;
}
}
i += text[i..].chars().next().map_or(1, char::len_utf8);
}
out
}
pub(super) fn autolink_bare_urls(
lines: Vec<Line<'static>>,
in_targets: Vec<String>,
) -> (Vec<Line<'static>>, Vec<String>) {
use ratatui::style::{Color, Modifier, Style};
let link_style = Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED);
let mut out_lines = Vec::with_capacity(lines.len());
let mut out_targets = Vec::new();
let mut ti = 0usize;
for line in lines {
if crate::preview::markdown::is_code_line(&line) {
for span in &line.spans {
if is_link_span(span) {
out_targets.push(in_targets.get(ti).cloned().unwrap_or_default());
ti += 1;
}
}
out_lines.push(line);
continue;
}
let style = line.style;
let mut new: Vec<Span<'static>> = Vec::with_capacity(line.spans.len());
for span in line.spans {
if is_link_span(&span) {
out_targets.push(in_targets.get(ti).cloned().unwrap_or_default());
ti += 1;
new.push(span);
continue;
}
if span.style.bg.is_some()
|| crate::preview::markdown::is_inline_code_span(&span)
|| crate::preview::markdown::is_task_span(&span)
|| crate::preview::markdown::is_code_header_span(&span)
|| crate::preview::markdown::is_mermaid_header_span(&span)
|| crate::preview::markdown::is_hidden_link_target(&span)
{
new.push(span);
continue;
}
let text = span.content.into_owned();
let matches = find_bare_links(&text);
if matches.is_empty() {
new.push(Span::styled(text, span.style));
continue;
}
let mut pos = 0usize;
for (s, e, url) in matches {
if s > pos {
new.push(Span::styled(text[pos..s].to_string(), span.style));
}
new.push(Span::styled(text[s..e].to_string(), link_style));
out_targets.push(url);
pos = e;
}
if pos < text.len() {
new.push(Span::styled(text[pos..].to_string(), span.style));
}
}
out_lines.push(Line::from(new).style(style));
}
(out_lines, out_targets)
}
pub(super) fn replace_emoji_shortcodes(text: &str) -> Option<String> {
if !text.contains(':') {
return None;
}
let mut out = String::new();
let mut changed = false;
let mut rest = text;
while let Some(start) = rest.find(':') {
out.push_str(&rest[..start]);
let after = &rest[start + 1..];
if let Some(end) = after.find(':') {
let code = &after[..end];
let looks_like_code = !code.is_empty()
&& code
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '-'));
if looks_like_code {
if let Some(e) = emojis::get_by_shortcode(code) {
out.push_str(e.as_str());
changed = true;
rest = &after[end + 1..];
continue;
}
}
out.push(':');
rest = after;
} else {
out.push(':');
out.push_str(after);
rest = "";
}
}
out.push_str(rest);
changed.then_some(out)
}
pub(super) fn substitute_emoji(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|line| {
if crate::preview::markdown::is_code_line(&line) {
return line;
}
let style = line.style;
let spans = line
.spans
.into_iter()
.map(|span| {
if span.style.bg.is_some()
|| is_link_span(&span)
|| crate::preview::markdown::is_inline_code_span(&span)
|| crate::preview::markdown::is_task_span(&span)
|| crate::preview::markdown::is_code_header_span(&span)
|| crate::preview::markdown::is_mermaid_header_span(&span)
|| crate::preview::markdown::is_hidden_link_target(&span)
{
return span;
}
match replace_emoji_shortcodes(span.content.as_ref()) {
Some(new) => Span::styled(new, span.style),
None => span,
}
})
.collect::<Vec<_>>();
Line::from(spans).style(style)
})
.collect()
}