use crate::datatypes::values::Value;
use crate::okf::model::{AttachmentRef, Link, Profile, DEFAULT_CONN_TYPE, EMBEDS_CONN_TYPE};
use crate::okf::structure::{self, BlockTree};
use regex::{Captures, Match, Regex};
use std::borrow::Cow;
use std::sync::OnceLock;
fn image_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r#"!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)"#).unwrap())
}
fn link_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r#"\[((?:!\[[^\]]*\]\([^)\s]*(?:\s+"[^"]*")?\)|[^\]])*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)"#)
.unwrap()
})
}
fn wikilink_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\[\[([^\]|\n]+)(?:\|([^\]\n]*))?\]\]").unwrap())
}
pub(crate) struct WikiRef<'t> {
pub name: &'t str,
pub anchor: Option<&'t str>,
pub alias: Option<&'t str>,
}
fn wikilink_parts<'t>(raw_name: &'t str, alias: Option<&'t str>) -> WikiRef<'t> {
let raw = match alias {
Some(_) => raw_name.strip_suffix('\\').unwrap_or(raw_name),
None => raw_name,
};
let (name, anchor) = match raw.split_once('#') {
Some((n, a)) => (n.trim(), Some(a.trim())),
None => (raw.trim(), None),
};
WikiRef {
name,
anchor,
alias: alias.map(str::trim).filter(|a| !a.is_empty()),
}
}
pub(crate) fn first_wikilink(text: &str) -> Option<WikiRef<'_>> {
wikilink_re().captures_iter(text).find_map(|cap| {
let m = cap.get(0).expect("the whole match");
(m.start() == 0 || text.as_bytes()[m.start() - 1] != b'!').then(|| {
wikilink_parts(
cap.get(1).expect("the name group").as_str(),
cap.get(2).map(|a| a.as_str()),
)
})
})
}
fn only_wikilink_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\s*\[\[([^\]|]+)(?:\|([^\]]*))?\]\]\s*$").unwrap())
}
pub(crate) fn conn_from_heading(heading: &str, profile: &Profile) -> Option<String> {
if let Some((_, edge)) = profile
.heading_edges
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(heading))
{
return Some(edge.clone());
}
let h = heading.to_ascii_lowercase();
let built_in = if h.contains("citation") {
"CITES"
} else if h.contains("join") {
"JOINS_WITH"
} else if h.contains("reference") {
"REFERENCES"
} else if h.contains("related") {
"RELATED"
} else if h.contains("depend") {
"DEPENDS_ON"
} else {
return None;
};
Some(built_in.to_string())
}
fn conn_from_title(title: &str) -> Option<String> {
let t = title.trim();
if !t.is_empty()
&& t.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
&& t.chars().next().is_some_and(|c| c.is_ascii_uppercase())
{
Some(t.to_string())
} else {
None
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Extraction {
pub links: Vec<Link>,
pub tags: Vec<String>,
pub attachments: Vec<AttachmentRef>,
pub path_errors: Vec<String>,
pub(crate) warnings: Vec<String>,
pub(crate) tag_spans: Vec<TagRef>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct TagRef {
pub name: String,
pub range: std::ops::Range<usize>,
}
pub fn extract(body: &str, source_dir: &str, profile: &Profile) -> Extraction {
extract_with_tree(body, &structure::parse_blocks(body), source_dir, profile)
}
pub(crate) fn extract_with_tree(
body: &str,
tree: &BlockTree,
source_dir: &str,
profile: &Profile,
) -> Extraction {
let mut out = Extraction::default();
let masked = structure::mask_code_spans(body, tree);
for range in structure::scan_regions(body, tree) {
let text = RegionText {
masked: &masked[range.clone()],
raw: &body[range.clone()],
};
let heading = structure::heading_at(tree, range.start);
let region = Region {
source_dir,
profile,
section: heading.map(|h| h.text.as_str()).filter(|h| !h.is_empty()),
heading_conn: heading.and_then(|h| conn_from_heading(&h.text, profile)),
};
if profile.inline_tags {
let mut at = range.start;
for line in text.masked.split_inclusive('\n') {
scan_tags(line, at, &mut out.tag_spans);
at += line.len();
}
}
region.scan(text, &mut out);
}
for tag in &out.tag_spans {
if !out.tags.contains(&tag.name) {
out.tags.push(tag.name.clone());
}
}
out
}
struct Region<'a> {
source_dir: &'a str,
profile: &'a Profile,
section: Option<&'a str>,
heading_conn: Option<String>,
}
#[derive(Clone, Copy)]
struct RegionText<'t> {
masked: &'t str,
raw: &'t str,
}
impl<'t> RegionText<'t> {
fn of(&self, m: Match<'_>) -> &'t str {
&self.raw[m.start()..m.end()]
}
fn group(&self, cap: &Captures<'_>, index: usize) -> &'t str {
cap.get(index).map_or("", |m| self.of(m))
}
fn sub(&self, m: Match<'_>) -> RegionText<'t> {
RegionText {
masked: &self.masked[m.start()..m.end()],
raw: self.of(m),
}
}
}
enum Found<'t> {
Markdown(Captures<'t>),
Wikilink(Captures<'t>),
}
enum TypeSuffix<'t> {
Absent,
Named(String),
Unusable(&'t str),
}
impl Region<'_> {
fn scan(&self, text: RegionText<'_>, out: &mut Extraction) {
let mut found: Vec<Found<'_>> = link_re()
.captures_iter(text.masked)
.map(Found::Markdown)
.collect();
if self.profile.wikilinks {
found.extend(
wikilink_re()
.captures_iter(text.masked)
.map(Found::Wikilink),
);
}
found.sort_by_key(Found::start);
for one in found {
match one {
Found::Markdown(cap) => self.markdown(text, &cap, out),
Found::Wikilink(cap) => self.wikilink(text, &cap, out),
}
}
}
fn markdown(&self, text: RegionText<'_>, cap: &Captures<'_>, out: &mut Extraction) {
let m = cap.get(0).expect("the whole match");
let decoded = percent_decode(text.group(cap, 2));
let dest = decoded.as_ref();
if m.start() > 0 && text.masked.as_bytes()[m.start() - 1] == b'!' {
let label = text.group(cap, 1);
self.check_path(dest, out);
self.attachment(dest, Some(label), out);
return;
}
let alt = match cap.get(1) {
Some(label) => self.inner_images(text.sub(label), out),
None => "",
};
if !is_external_url(dest) {
self.check_path(dest, out);
}
let conn = cap
.get(3)
.and_then(|t| conn_from_title(text.of(t)))
.or_else(|| self.heading_conn.clone())
.unwrap_or_else(|| DEFAULT_CONN_TYPE.to_string());
let props = edge_props(self.profile, self.section, fragment_of(dest), None);
if is_external_url(dest) {
push_unique(
&mut out.links,
Link {
target: dest.to_string(),
conn_type: conn,
is_external: true,
props,
reverse: false,
},
);
} else if let Some(target) = resolve_target(dest, self.source_dir) {
push_unique(
&mut out.links,
Link {
target,
conn_type: conn,
is_external: false,
props,
reverse: false,
},
);
} else {
self.attachment(dest, Some(alt), out);
}
}
fn wikilink(&self, text: RegionText<'_>, cap: &Captures<'_>, out: &mut Extraction) {
let m = cap.get(0).expect("the whole match");
let is_embed = m.start() > 0 && text.masked.as_bytes()[m.start() - 1] == b'!';
let parts = wikilink_parts(
text.of(cap.get(1).expect("the name group")),
cap.get(2).map(|a| text.of(a)),
);
let (name, anchor) = (parts.name, parts.anchor);
if name.is_empty() {
return;
}
if self.profile.path_safety {
if is_embed && !embeds_a_note(name) {
record_path_error(&mut out.path_errors, name, self.source_dir);
} else {
record_wikilink_path_error(&mut out.path_errors, name, self.source_dir);
}
}
let target = name.trim_end_matches(".md");
let conn = if is_embed {
if !embeds_a_note(name) {
self.attachment(name, parts.alias, out);
return;
}
if !self.profile.embeds {
return;
}
EMBEDS_CONN_TYPE.to_string()
} else {
match self.type_suffix(text, m.end()) {
TypeSuffix::Named(conn) => conn,
TypeSuffix::Unusable(suffix) => {
let warning = format!(
"`{}{suffix}`: a link type holds no whitespace and must normalise to a \
name that does not start with a digit — the brace is left as prose",
text.of(m)
);
if !out.warnings.contains(&warning) {
out.warnings.push(warning);
}
self.untyped_conn()
}
TypeSuffix::Absent => self.untyped_conn(),
}
};
push_unique(
&mut out.links,
Link {
target: target.to_string(),
conn_type: conn,
is_external: false,
props: edge_props(self.profile, self.section, anchor, parts.alias),
reverse: false,
},
);
}
fn untyped_conn(&self) -> String {
self.heading_conn
.clone()
.unwrap_or_else(|| DEFAULT_CONN_TYPE.to_string())
}
fn type_suffix<'t>(&self, text: RegionText<'t>, at: usize) -> TypeSuffix<'t> {
if !self.profile.typed_links {
return TypeSuffix::Absent;
}
let Some(rest) = text.masked.get(at..).filter(|r| r.starts_with('{')) else {
return TypeSuffix::Absent;
};
let line = &rest[..rest.find('\n').unwrap_or(rest.len())];
let Some(close) = line.find('}') else {
return TypeSuffix::Absent;
};
let suffix = &text.raw[at..at + close + 1];
let inner = &text.raw[at + 1..at + close];
let conn = upper_snake(inner);
if inner.contains(char::is_whitespace)
|| conn.is_empty()
|| conn.starts_with(|c: char| c.is_ascii_digit())
{
return TypeSuffix::Unusable(suffix);
}
TypeSuffix::Named(conn)
}
fn inner_images<'t>(&self, label: RegionText<'t>, out: &mut Extraction) -> &'t str {
let mut alt = label.raw;
for image in image_re().captures_iter(label.masked) {
let decoded = percent_decode(label.group(&image, 2));
self.check_path(decoded.as_ref(), out);
let inner = label.group(&image, 1);
self.attachment(decoded.as_ref(), Some(inner), out);
if image
.get(0)
.is_some_and(|m| m.as_str() == label.masked.trim())
{
alt = inner;
}
}
alt
}
fn attachment(&self, dest: &str, alt: Option<&str>, out: &mut Extraction) {
push_attachment(&mut out.attachments, self.profile, dest, alt, self.section);
}
fn check_path(&self, dest: &str, out: &mut Extraction) {
if self.profile.path_safety {
record_path_error(&mut out.path_errors, dest, self.source_dir);
}
}
}
impl Found<'_> {
fn start(&self) -> usize {
match self {
Found::Markdown(cap) | Found::Wikilink(cap) => {
cap.get(0).expect("the whole match").start()
}
}
}
}
fn fragment_of(dest: &str) -> Option<&str> {
let frag = dest.split_once('#')?.1;
let frag = frag.split('?').next().unwrap_or(frag);
(!frag.is_empty()).then_some(frag)
}
fn edge_props(
profile: &Profile,
section: Option<&str>,
anchor: Option<&str>,
label: Option<&str>,
) -> Vec<(String, Value)> {
if !profile.link_edge_props {
return Vec::new();
}
let mut props = Vec::new();
if let Some(s) = section {
props.push(("section".to_string(), Value::String(s.to_string())));
}
if let Some(a) = anchor.filter(|a| !a.is_empty()) {
props.push(("anchor".to_string(), Value::String(a.to_string())));
}
if let Some(l) = label.filter(|l| !l.is_empty() && profile.structure.is_some()) {
props.push(("label".to_string(), Value::String(l.to_string())));
}
props
}
fn push_attachment(
out: &mut Vec<AttachmentRef>,
profile: &Profile,
dest: &str,
alt: Option<&str>,
section: Option<&str>,
) {
if !profile.attachments || has_uri_scheme(dest) {
return;
}
let target = dest.split(['#', '?']).next().unwrap_or(dest).trim();
if target.is_empty() || embeds_a_note(target) {
return;
}
out.push(AttachmentRef {
target: target.to_string(),
alt: alt
.map(str::trim)
.filter(|a| !a.is_empty())
.map(str::to_string),
section: section.map(str::to_string),
});
}
fn embeds_a_note(name: &str) -> bool {
let file = name.rsplit('/').next().unwrap_or(name);
match file.rsplit_once('.') {
None => true,
Some((_, ext)) => ext.eq_ignore_ascii_case("md"),
}
}
fn scan_tags(line: &str, base: usize, out: &mut Vec<TagRef>) {
let masked = mask_code_spans(line);
let mut cursor = 0;
while let Some(pos) = masked[cursor..].find('#') {
let at = cursor + pos;
cursor = at + 1;
if at > 0
&& !masked[..at]
.chars()
.next_back()
.is_some_and(char::is_whitespace)
{
continue;
}
let name: String = masked[at + 1..]
.chars()
.take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '/'))
.collect();
if name.is_empty() || !name.chars().any(char::is_alphabetic) {
continue;
}
cursor = at + 1 + name.len();
out.push(TagRef {
range: base + at..base + cursor,
name,
});
}
}
fn mask_code_spans(line: &str) -> String {
let chars: Vec<char> = line.chars().collect();
let mut out = String::with_capacity(line.len());
let mut i = 0;
while i < chars.len() {
if chars[i] != '`' {
out.push(chars[i]);
i += 1;
continue;
}
let open = i;
while i < chars.len() && chars[i] == '`' {
i += 1;
}
let run = i - open;
let mut j = i;
let close = loop {
if j >= chars.len() {
break None;
}
if chars[j] == '`' {
let s = j;
while j < chars.len() && chars[j] == '`' {
j += 1;
}
if j - s == run {
break Some(j);
}
} else {
j += 1;
}
};
let end = close.unwrap_or(i);
for masked in &chars[open..end] {
for _ in 0..masked.len_utf8() {
out.push('\u{0}');
}
}
i = end;
}
out
}
pub(crate) fn wikilink_targets(v: &Value) -> Option<Vec<String>> {
let one = |s: &str| -> Option<String> {
let cap = only_wikilink_re().captures(s)?;
let parts = wikilink_parts(cap.get(1)?.as_str(), cap.get(2).map(|a| a.as_str()));
let name = parts.name.trim_end_matches(".md");
(!name.is_empty()).then(|| name.to_string())
};
match v {
Value::String(s) => one(s).map(|t| vec![t]),
Value::List(items) => {
if items.is_empty() {
return None;
}
items
.iter()
.map(|x| match x {
Value::String(s) => one(s),
_ => None,
})
.collect()
}
_ => None,
}
}
pub(crate) fn upper_snake(key: &str) -> String {
let mut out = String::with_capacity(key.len());
let mut pending_sep = false;
for ch in key.chars() {
if ch.is_alphanumeric() {
if pending_sep && !out.is_empty() {
out.push('_');
}
pending_sep = false;
out.extend(ch.to_uppercase());
} else {
pending_sep = true;
}
}
out
}
pub(crate) fn push_unique(out: &mut Vec<Link>, link: Link) {
if !out.contains(&link) {
out.push(link);
}
}
fn is_external_url(dest: &str) -> bool {
dest.starts_with("http://") || dest.starts_with("https://")
}
fn has_uri_scheme(dest: &str) -> bool {
let Some((scheme, _)) = dest.split_once(':') else {
return false;
};
scheme.len() >= 2
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
}
fn resolve_target(dest: &str, source_dir: &str) -> Option<String> {
let dest = dest.split(['#', '?']).next().unwrap_or(dest);
if dest.is_empty() {
return None;
}
if dest.contains("://") || dest.starts_with("mailto:") {
return None;
}
if !dest.ends_with(".md") {
return None;
}
let stem = &dest[..dest.len() - 3];
let normalized = if let Some(abs) = stem.strip_prefix('/') {
normalize_path_parts(abs.split('/'))
} else {
let mut parts: Vec<&str> = if source_dir.is_empty() {
Vec::new()
} else {
source_dir.split('/').collect()
};
let combined = parts
.drain(..)
.chain(stem.split('/'))
.collect::<Vec<_>>()
.join("/");
normalize_path_parts(combined.split('/'))
};
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
pub(crate) fn percent_decode(target: &str) -> Cow<'_, str> {
if !target.contains('%') {
return Cow::Borrowed(target);
}
let bytes = target.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let decoded = (bytes[i] == b'%' && i + 2 < bytes.len())
.then(|| {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?;
u8::from_str_radix(hex, 16).ok()
})
.flatten();
match decoded {
Some(byte) => {
out.push(byte);
i += 3;
}
None => {
out.push(bytes[i]);
i += 1;
}
}
}
match String::from_utf8(out) {
Ok(text) => Cow::Owned(text),
Err(_) => Cow::Borrowed(target),
}
}
pub(crate) fn path_error(target: &str, source_dir: &str) -> Option<String> {
if is_absolute_fs_path(target) {
return Some(format!("`{target}` is an absolute filesystem path"));
}
escapes_root(target, source_dir).then(|| format!("`{target}` escapes the vault root"))
}
fn is_absolute_fs_path(target: &str) -> bool {
let bytes = target.as_bytes();
let drive = matches!(bytes, [letter, b':', sep, ..]
if letter.is_ascii_alphabetic() && (*sep == b'/' || *sep == b'\\'));
drive
|| target.starts_with('\\')
|| target == "~"
|| target.starts_with("~/")
|| target
.get(..5)
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:"))
}
fn escapes_root(target: &str, source_dir: &str) -> bool {
let mut depth: isize = if target.starts_with('/') || source_dir.is_empty() {
0
} else {
source_dir.split('/').filter(|p| !p.is_empty()).count() as isize
};
for part in target.split(['/', '\\']) {
match part {
"" | "." => {}
".." => {
depth -= 1;
if depth < 0 {
return true;
}
}
_ => depth += 1,
}
}
false
}
pub(crate) fn record_wikilink_path_error(out: &mut Vec<String>, name: &str, source_dir: &str) {
if name.contains('/') || is_absolute_fs_path(name) {
record_path_error(out, name, source_dir);
}
}
fn record_path_error(out: &mut Vec<String>, target: &str, source_dir: &str) {
if let Some(message) = path_error(target, source_dir) {
if !out.contains(&message) {
out.push(message);
}
}
}
pub(crate) fn normalize_path_parts<'a>(parts: impl Iterator<Item = &'a str>) -> String {
let mut stack: Vec<&str> = Vec::new();
for p in parts {
match p {
"" | "." => {}
".." => {
stack.pop();
}
other => stack.push(other),
}
}
stack.join("/")
}
#[cfg(test)]
#[path = "links_tests.rs"]
mod links_tests;