use crate::code::{CodeRules, strip_code_with};
use crate::{Size, is_vault_link_url, md_link_target, parse_inner, parse_inner_self};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ScanOptions {
pub code_rules: CodeRules,
pub skip_html_comments: bool,
pub self_links: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BodyLinkKind {
Wikilink,
Embed,
Markdown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BodyLink {
pub kind: BodyLinkKind,
pub raw: String,
pub target: String,
pub heading: Option<String>,
pub block: Option<String>,
pub alias: Option<String>,
pub size: Option<Size>,
pub byte_start: usize,
pub byte_end: usize,
}
pub fn body_links_spanned(body: &str) -> Vec<BodyLink> {
body_links_spanned_with(body, &ScanOptions::default())
}
pub fn body_links_spanned_with(body: &str, options: &ScanOptions) -> Vec<BodyLink> {
let mut stripped = strip_code_with(body, options.code_rules);
if options.skip_html_comments {
stripped = blank_comments(&stripped);
}
let chars: Vec<char> = stripped.chars().collect();
let mut at: Vec<usize> = body.char_indices().map(|(i, _)| i).collect();
at.push(body.len());
if chars.len() + 1 != at.len() {
return Vec::new();
}
let mut out = Vec::new();
let mut scan = UrlScan::default();
let mut i = 0usize;
while i < chars.len() {
if chars[i] == '[' && chars.get(i + 1) == Some(&'[') {
let embed = i > 0 && chars[i - 1] == '!';
let mut j = i + 2;
let mut inner = String::new();
let mut closed = false;
while j + 1 < chars.len() {
if chars[j] == ']' && chars[j + 1] == ']' {
closed = true;
break;
}
if chars[j] == '[' || chars[j] == ']' {
break;
}
inner.push(chars[j]);
j += 1;
}
if closed && !inner.is_empty() {
let parsed = if options.self_links {
parse_inner_self(&inner, embed)
} else {
parse_inner(&inner, embed)
};
if let Some(link) = parsed {
let alias = chars[i + 2..j]
.iter()
.position(|&c| c == '|')
.map(|p| body[at[i + 3 + p]..at[j]].trim())
.filter(|alias| !alias.is_empty())
.map(str::to_string);
out.push(BodyLink {
kind: if embed {
BodyLinkKind::Embed
} else {
BodyLinkKind::Wikilink
},
raw: inner,
target: link.target,
heading: link.heading,
block: link.block,
size: link.size,
alias,
byte_start: at[if embed { i - 1 } else { i }],
byte_end: at[j + 2],
});
}
i = j + 2;
continue;
}
}
if chars[i] == '['
&& let Some(link) = spanned_markdown_link(&chars, i, &at, &mut scan)
{
i = link.1;
out.push(link.0);
continue;
}
i += 1;
}
out
}
fn blank_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(open) = rest.find("<!--") {
out.push_str(&rest[..open]);
let after = &rest[open + 4..];
let end = after.find("-->").map_or(rest.len(), |at| open + 4 + at + 3);
out.extend(
rest[open..end]
.chars()
.map(|c| if c == '\n' { '\n' } else { ' ' }),
);
rest = &rest[end..];
}
out.push_str(rest);
out
}
fn spanned_markdown_link(
chars: &[char],
start: usize,
at: &[usize],
scan: &mut UrlScan,
) -> Option<(BodyLink, usize)> {
let mut j = start + 1;
while j < chars.len() && chars[j] != ']' && chars[j] != '[' {
j += 1;
}
if chars.get(j) != Some(&']') || chars.get(j + 1) != Some(&'(') {
return None;
}
let angled = chars.get(j + 2) == Some(&'<');
let url_start = if angled { j + 3 } else { j + 2 };
let url_end = scan.url_end(chars, url_start);
let mut end = url_end;
if angled {
if chars.get(end) != Some(&'>') {
return None;
}
end += 1;
}
if chars.get(end) != Some(&')') {
return None;
}
end += 1;
if !scan.is_vault_link(chars, url_start, url_end) || has_scheme(&chars[url_start..url_end]) {
return None;
}
let url: String = chars[url_start..url_end].iter().collect();
Some((
BodyLink {
kind: BodyLinkKind::Markdown,
target: md_link_target(&url),
heading: None,
block: None,
alias: None,
size: None,
raw: url,
byte_start: at[start],
byte_end: at[end],
},
end,
))
}
#[derive(Debug, Default)]
struct UrlScan {
stop: Option<(usize, usize)>,
tail: Option<UrlTail>,
}
#[derive(Debug)]
struct UrlTail {
end: usize,
from: usize,
mark: Option<usize>,
answers: Vec<(usize, bool)>,
}
impl UrlScan {
fn url_end(&mut self, chars: &[char], from: usize) -> usize {
if let Some((start, stop)) = self.stop
&& (start..=stop).contains(&from)
{
return stop;
}
let mut k = from;
while k < chars.len() && !matches!(chars[k], ')' | '>') && !chars[k].is_whitespace() {
k += 1;
}
self.stop = Some((from, k));
k
}
fn is_vault_link(&mut self, chars: &[char], from: usize, end: usize) -> bool {
let tail = match &mut self.tail {
Some(tail) if tail.end == end && tail.from <= from => tail,
slot => slot.insert(UrlTail {
end,
from,
mark: chars[from..end]
.iter()
.rposition(|c| matches!(c, '.' | '/'))
.map(|i| from + i),
answers: Vec::new(),
}),
};
let Some(mark) = tail.mark.filter(|&mark| mark >= from && chars[mark] == '.') else {
return false;
};
let tail_start = from.max(mark.saturating_sub(1));
if let Some(&(_, answer)) = tail.answers.iter().find(|(at, _)| *at == tail_start) {
return answer;
}
let answer = is_vault_link_url(&chars[tail_start..end].iter().collect::<String>());
tail.answers.push((tail_start, answer));
answer
}
}
fn has_scheme(url: &[char]) -> bool {
let mut chars = url.iter();
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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_url_scan_is_linear_in_failed_link_attempts() {
for n in [25_000, 50_000, 100_000] {
for body in [
"[](a".repeat(n),
format!("{})", "[](a".repeat(n)),
format!("{}.md)", "[](a:".repeat(n)),
format!("{}.x)", "[](a/".repeat(n)),
] {
let started = std::time::Instant::now();
assert!(body_links_spanned(&body).is_empty());
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(10),
"{} KB took {elapsed:?}",
body.len() / 1000
);
}
}
}
}