use crate::datatypes::values::Value;
use crate::okf::model::{AttachmentRef, Link, Profile, DEFAULT_CONN_TYPE, EMBEDS_CONN_TYPE};
use regex::Regex;
use std::borrow::Cow;
use std::sync::OnceLock;
fn link_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r#"\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)"#).unwrap())
}
fn wikilink_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]*))?\]\]").unwrap())
}
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())
}
pub(crate) fn heading_text(trimmed: &str) -> Option<&str> {
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
if hashes == 0 || hashes > 6 {
return None;
}
let rest = &trimmed[hashes..];
if rest.is_empty() || rest.starts_with([' ', '\t']) {
Some(rest.trim())
} else {
None
}
}
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 fn extract(body: &str, source_dir: &str, profile: &Profile) -> Extraction {
let mut out = Extraction::default();
let mut current_heading: Option<String> = None;
let mut in_fence = false;
for raw in body.lines() {
let trimmed = raw.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let line = match heading_text(trimmed) {
Some(h) => {
current_heading = Some(h.to_string());
current_heading.as_deref().unwrap_or(raw)
}
None => raw,
};
if profile.inline_tags {
scan_tags(line, &mut out.tags);
}
let heading_conn = current_heading
.as_deref()
.and_then(|h| conn_from_heading(h, profile));
let section = current_heading.as_deref().filter(|h| !h.is_empty());
for cap in link_re().captures_iter(line) {
let m = cap.get(0).unwrap();
let decoded = percent_decode(cap.get(2).map(|d| d.as_str()).unwrap_or(""));
let dest = decoded.as_ref();
if m.start() > 0 && line.as_bytes()[m.start() - 1] == b'!' {
if profile.path_safety {
record_path_error(&mut out.path_errors, dest, source_dir);
}
push_attachment(
&mut out.attachments,
profile,
dest,
cap.get(1).map(|t| t.as_str()),
section,
);
continue;
}
if profile.path_safety && !is_external_url(dest) {
record_path_error(&mut out.path_errors, dest, source_dir);
}
let conn = cap
.get(3)
.and_then(|t| conn_from_title(t.as_str()))
.or_else(|| heading_conn.clone())
.unwrap_or_else(|| DEFAULT_CONN_TYPE.to_string());
let props = edge_props(profile, section, fragment_of(dest));
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, source_dir) {
push_unique(
&mut out.links,
Link {
target,
conn_type: conn,
is_external: false,
props,
reverse: false,
},
);
} else {
push_attachment(
&mut out.attachments,
profile,
dest,
cap.get(1).map(|t| t.as_str()),
section,
);
}
}
if profile.wikilinks {
for cap in wikilink_re().captures_iter(line) {
let m = cap.get(0).unwrap();
let is_embed = m.start() > 0 && line.as_bytes()[m.start() - 1] == b'!';
let raw_name = cap.get(1).unwrap().as_str();
let (name, anchor) = match raw_name.split_once('#') {
Some((n, a)) => (n.trim(), Some(a.trim())),
None => (raw_name.trim(), None),
};
if name.is_empty() {
continue;
}
if profile.path_safety {
if is_embed && !embeds_a_note(name) {
record_path_error(&mut out.path_errors, name, source_dir);
} else {
record_wikilink_path_error(&mut out.path_errors, name, source_dir);
}
}
let target = name.trim_end_matches(".md");
let conn = if is_embed {
if !embeds_a_note(name) {
push_attachment(
&mut out.attachments,
profile,
name,
cap.get(2).map(|a| a.as_str()),
section,
);
continue;
}
if !profile.embeds {
continue;
}
EMBEDS_CONN_TYPE.to_string()
} else {
heading_conn
.clone()
.unwrap_or_else(|| DEFAULT_CONN_TYPE.to_string())
};
push_unique(
&mut out.links,
Link {
target: target.to_string(),
conn_type: conn,
is_external: false,
props: edge_props(profile, section, anchor),
reverse: false,
},
);
}
}
}
out
}
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>,
) -> 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())));
}
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, out: &mut Vec<String>) {
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();
if !out.contains(&name) {
out.push(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 _ in open..end {
out.push('\u{0}');
}
i = end;
}
out
}
pub(crate) fn wikilink_targets(v: &Value) -> Option<Vec<String>> {
let one = |s: &str| -> Option<String> {
let name = only_wikilink_re().captures(s)?.get(1)?.as_str();
let name = name.split('#').next().unwrap_or(name).trim();
let name = 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.len() >= 5 && target[..5].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)]
mod tests {
use super::*;
use crate::okf::model::Dialect;
fn extract_links(body: &str, source_dir: &str, dialect: Dialect) -> Vec<Link> {
extract(body, source_dir, &Profile::for_dialect(dialect)).links
}
fn vault() -> Profile {
Profile::for_dialect(Dialect::Obsidian)
}
fn props_of(link: &Link) -> Vec<(&str, &str)> {
link.props
.iter()
.map(|(k, v)| {
(
k.as_str(),
match v {
Value::String(s) => s.as_str(),
_ => panic!("edge props are strings"),
},
)
})
.collect()
}
#[test]
fn titled_link_yields_typed_edge() {
let body = "Joined with [customers](/tables/customers.md \"JOINS_WITH\") here.";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links.len(), 1);
assert_eq!(links[0].target, "tables/customers");
assert_eq!(links[0].conn_type, "JOINS_WITH");
}
#[test]
fn section_header_inference() {
let body = "# Citations\n[1] [src](/references/x.md)\n# Joins\nsee [y](/tables/y.md)";
let links = extract_links(body, "tables", Dialect::Okf);
let by_target: std::collections::HashMap<_, _> = links
.iter()
.map(|l| (l.target.as_str(), l.conn_type.as_str()))
.collect();
assert_eq!(by_target.get("references/x"), Some(&"CITES"));
assert_eq!(by_target.get("tables/y"), Some(&"JOINS_WITH"));
}
#[test]
fn untyped_link_defaults_to_links_to() {
let body = "See [other](./other.md) for details.";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links[0].target, "tables/other");
assert_eq!(links[0].conn_type, "LINKS_TO");
}
#[test]
fn relative_parent_paths_resolve() {
let body = "Part of the [sales dataset](../datasets/sales.md).";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links[0].target, "datasets/sales");
}
#[test]
fn tooltip_title_is_not_a_type() {
let body = "See [customers](/tables/customers.md \"the customers table\").";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links[0].conn_type, "LINKS_TO");
}
#[test]
fn external_captured_non_md_skipped() {
let body =
"# Citations\n[site](https://example.com) and [dir](subdir/) and [doc](./pic.png)";
let links = extract_links(body, "", Dialect::Okf);
assert_eq!(links.len(), 1);
assert!(links[0].is_external);
assert_eq!(links[0].target, "https://example.com");
assert_eq!(links[0].conn_type, "CITES");
}
#[test]
fn images_and_fenced_code_skipped() {
let body =
"\n```sql\nSELECT [a](/tables/y.md)\n```\n[real](/tables/z.md)";
let links = extract_links(body, "tables", Dialect::Okf);
assert_eq!(links.len(), 1);
assert_eq!(links[0].target, "tables/z");
}
#[test]
fn wikilinks_only_in_loose_dialect() {
let body = "See [[other-note]] and [[sub/thing|alias]].";
assert!(extract_links(body, "", Dialect::Okf).is_empty());
let links = extract_links(body, "", Dialect::Loose);
assert_eq!(links.len(), 2);
assert_eq!(links[0].target, "other-note");
assert_eq!(links[1].target, "sub/thing");
}
#[test]
fn embedded_wikilink_is_not_a_link() {
let links = extract_links(
"![[diagram.png]] and ![[note|alias]] but [[real-note]]",
"",
Dialect::Loose,
);
let targets: Vec<&str> = links.iter().map(|l| l.target.as_str()).collect();
assert_eq!(targets, vec!["real-note"]);
}
#[test]
fn tag_line_is_not_a_heading_and_keeps_its_links() {
let links = extract_links("#project see [[Alice]]", "", Dialect::Loose);
assert_eq!(links.len(), 1, "a `#tag` line is prose, not a heading");
assert_eq!(links[0].target, "Alice");
assert_eq!(links[0].conn_type, "LINKS_TO");
}
#[test]
fn heading_text_requires_a_space_and_at_most_six_hashes() {
assert_eq!(heading_text("# Joins"), Some("Joins"));
assert_eq!(heading_text("###\tDeps"), Some("Deps"));
assert_eq!(heading_text("#"), Some(""));
assert_eq!(heading_text("#related"), None);
assert_eq!(heading_text("####### Deep"), None);
assert_eq!(heading_text("plain"), None);
}
#[test]
fn real_heading_still_types_the_links_below_it() {
let links = extract_links(
"# Related work\n#seealso\nsee [[Alice]]",
"",
Dialect::Loose,
);
assert_eq!(links.len(), 1);
assert_eq!(
links[0].conn_type, "RELATED",
"heading ladder still applies"
);
}
#[test]
fn loose_dialect_link_set_is_unchanged() {
let body = "see [[other-note]], ![[img.png]], [x](/tables/y.md) and #tag [[Alice]]";
let got = extract(body, "tables", &Profile::for_dialect(Dialect::Loose));
let links: Vec<(&str, &str, bool)> = got
.links
.iter()
.map(|l| (l.target.as_str(), l.conn_type.as_str(), l.props.is_empty()))
.collect();
assert_eq!(
links,
vec![
("tables/y", "LINKS_TO", true),
("other-note", "LINKS_TO", true),
("Alice", "LINKS_TO", true),
]
);
assert!(got.tags.is_empty(), "inline tags are a vault rule only");
}
#[test]
fn wikilink_anchor_is_stripped() {
let links = extract_links(
"see [[Design Notes#Goals]] and [[api#parse]]",
"",
Dialect::Loose,
);
let targets: Vec<&str> = links.iter().map(|l| l.target.as_str()).collect();
assert_eq!(targets, vec!["Design Notes", "api"]);
}
#[test]
fn vault_body_link_carries_its_section() {
let got = extract(
"Above every heading: [[atlas]].\n\n## Deep dive\n\nBelow one: [[bob]].",
"",
&vault(),
);
assert_eq!(
props_of(&got.links[0]),
Vec::new(),
"above the first heading"
);
assert_eq!(props_of(&got.links[1]), vec![("section", "Deep dive")]);
}
#[test]
fn vault_fragment_link_carries_its_anchor() {
let got = extract(
"## Notes\n[[atlas#Overview]] and [x](sub/b.md#usage) and [[bob#^b-12]]",
"",
&vault(),
);
let by_target: std::collections::HashMap<&str, Vec<(&str, &str)>> = got
.links
.iter()
.map(|l| (l.target.as_str(), props_of(l)))
.collect();
assert_eq!(
by_target["atlas"],
vec![("section", "Notes"), ("anchor", "Overview")]
);
assert_eq!(
by_target["sub/b"],
vec![("section", "Notes"), ("anchor", "usage")],
"a path link's fragment is an anchor too, and never part of the target"
);
assert_eq!(
by_target["bob"],
vec![("section", "Notes"), ("anchor", "^b-12")],
"a block reference keeps its caret"
);
}
#[test]
fn a_heading_line_states_its_own_links_and_pictures() {
let got = extract(
concat!(
"## Gallery  beside [[atlas]]\n",
"Below it: [[bob]].\n",
),
"",
&vault(),
);
assert_eq!(
attach(&got),
vec beside [[atlas]]")
)],
"the picture is referenced, and its section is the heading it sits in"
);
let links: Vec<(&str, Vec<(&str, &str)>)> = got
.links
.iter()
.map(|l| (l.target.as_str(), props_of(l)))
.collect();
assert_eq!(
links,
vec beside [[atlas]]"
)]
),
(
"bob",
vec beside [[atlas]]"
)]
),
],
"one section string for the heading's own link and for the line \
below it — two values would split one section into two edge groups"
);
}
#[test]
fn a_heading_lines_markdown_link_takes_the_headings_own_edge_type() {
let got = extract(
"## Related work, see [Alice](people/alice.md)\n",
"",
&vault(),
);
assert_eq!(got.links.len(), 1);
assert_eq!(got.links[0].target, "people/alice");
assert_eq!(
got.links[0].conn_type, "RELATED",
"the heading types the link written on it, as it types the ones below"
);
let escaping = extract("## See \n", "", &vault());
assert_eq!(
escaping.path_errors,
vec!["`../../etc/passwd.png` escapes the vault root".to_string()],
"a reference on a heading meets §9 like any other"
);
}
#[test]
fn a_heading_line_is_scanned_in_every_dialect() {
for dialect in [Dialect::Okf, Dialect::Loose] {
let got = extract(
"## See [Alice](people/alice.md) #tag \n",
"",
&Profile::for_dialect(dialect),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["people/alice"],
"{dialect:?} reads the link and drops the image, as it does in prose"
);
assert!(got.attachments.is_empty(), "{dialect:?}");
assert!(got.tags.is_empty(), "{dialect:?}");
assert!(got.links[0].props.is_empty(), "{dialect:?}");
}
}
#[test]
fn vault_embed_of_a_note_is_an_edge_of_a_file_is_not() {
let got = extract("![[old]] ![[notes/deep.md]] ![[diagram.png]]", "", &vault());
let got: Vec<(&str, &str)> = got
.links
.iter()
.map(|l| (l.target.as_str(), l.conn_type.as_str()))
.collect();
assert_eq!(
got,
vec![("old", "EMBEDS"), ("notes/deep", "EMBEDS")],
"`.png` is an attachment, not a link"
);
}
#[test]
fn loose_still_drops_every_embed() {
let links = extract_links("![[old]] and ![[diagram.png]]", "", Dialect::Loose);
assert!(links.is_empty(), "EMBEDS is a vault rule");
}
#[test]
fn vault_inline_tags_honour_the_four_exclusions() {
let body = concat!(
"# Heading #inhead\n",
"A #plain tag and a #kebab-case/nested one.\n",
"Not in a `span with #incode in it`, not in https://ex.com/p#frag,\n",
"not in [[Note#Section]], and #2026 is not a tag.\n",
"```\n#infence\n```\n",
"#plain again is not a second tag.\n",
);
let got = extract(body, "", &vault());
assert_eq!(
got.tags,
vec!["inhead", "plain", "kebab-case/nested"],
"a heading's own `#` is not a tag, but a tag written in one is"
);
}
#[test]
fn loose_extracts_no_inline_tags() {
assert!(
extract("A #plain tag.", "", &Profile::for_dialect(Dialect::Loose))
.tags
.is_empty()
);
}
fn attach(got: &Extraction) -> Vec<(&str, Option<&str>, Option<&str>)> {
got.attachments
.iter()
.map(|a| (a.target.as_str(), a.alt.as_deref(), a.section.as_deref()))
.collect()
}
#[test]
fn vault_captures_all_three_attachment_spellings() {
let got = extract(
concat!(
" and ![[plain.png]]\n",
"## Figures\n",
" then ![[diagram.png|A diagram]]\n",
"and  has no alt, ![[notes/deep.md]] is a note,\n",
"![[nameless]] is a note too, and \n",
"and  drops its fragment.\n",
" and  are neither.\n",
),
"notes",
&vault(),
);
assert_eq!(
attach(&got),
vec![
("img/bare.png", None, None),
("plain.png", None, None),
("../img/faults.png", Some("Fault map"), Some("Figures")),
("diagram.png", Some("A diagram"), Some("Figures")),
("img/blank.png", None, Some("Figures")),
("img/frag.png", Some("frag"), Some("Figures")),
],
"an external URL, a `.md` target and an extension-less one are not \
attachments — in either spelling"
);
assert!(
!got.links.iter().any(|l| l.target.contains("other")),
"and `` is not a link either: the `!` still \
disqualifies it"
);
let embeds: Vec<&str> = got
.links
.iter()
.filter(|l| l.conn_type == EMBEDS_CONN_TYPE)
.map(|l| l.target.as_str())
.collect();
assert_eq!(
embeds,
vec!["notes/deep", "nameless"],
"the note embeds on those lines are still links"
);
}
#[test]
fn vault_plain_link_to_a_file_is_an_attachment_reference() {
let got = extract(
concat!(
"## Downloads\n",
"[The handbook](../img/handbook.pdf) and [a map](../img/faults.png),\n",
"[back to top](#downloads) and [the note](other.md) are not,\n",
"and neither are [mail](mailto:a@b.com), [dir](sub/) or\n",
"[site](https://example.com/x.zip).\n",
),
"notes",
&vault(),
);
assert_eq!(
attach(&got),
vec![
(
"../img/handbook.pdf",
Some("The handbook"),
Some("Downloads")
),
("../img/faults.png", Some("a map"), Some("Downloads")),
],
"the link text is the reference's alt, exactly as ``'s is"
);
let links: Vec<&str> = got.links.iter().map(|l| l.target.as_str()).collect();
assert_eq!(
links,
vec!["notes/other", "https://example.com/x.zip"],
"a `.md` target is still a note link and an http one still a Source"
);
assert!(
got.path_errors.is_empty(),
"an in-page anchor, a mailto and a directory link are silent no-ops"
);
}
#[test]
fn okf_and_loose_drop_a_plain_link_to_a_file() {
for dialect in [Dialect::Okf, Dialect::Loose] {
let got = extract("[handbook](img/h.pdf)", "", &Profile::for_dialect(dialect));
assert!(
got.attachments.is_empty() && got.links.is_empty(),
"{dialect:?} reads no attachments, so the link stays dropped"
);
}
}
#[test]
fn an_escaped_wikilink_is_literal_text() {
let got = extract(r"Write \[\[atlas]] to mean the literal text.", "", &vault());
assert!(
got.links.is_empty() && got.attachments.is_empty(),
"an escaped wikilink names nothing"
);
}
#[test]
fn an_indented_code_block_is_scanned_like_prose() {
let got = extract(
"## Example\n\n [[atlas]] and \n",
"",
&vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["atlas"],
"four-space indentation exempts nothing; fence it instead"
);
assert_eq!(
attach(&got),
vec![("img/x.png", Some("m"), Some("Example"))]
);
}
#[test]
fn markdown_syntax_inside_an_html_block_is_scanned() {
let got = extract(
concat!(
"## Gallery\n",
"<div><a href=\"bob.md\">bob</a> <img src=\"img/y.png\"></div>\n",
"<div>[[atlas]] and </div>\n",
),
"",
&vault(),
);
assert_eq!(
got.links
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["atlas"],
"`<a href>` is not a link; the wikilink beside it is"
);
assert_eq!(
attach(&got),
vec![("img/x.png", Some("m"), Some("Gallery"))],
"`<img src>` is not a reference; the markdown image beside it is"
);
}
#[test]
fn okf_and_loose_capture_no_attachments() {
for dialect in [Dialect::Okf, Dialect::Loose] {
let got = extract(
" and ![[y.png]]",
"",
&Profile::for_dialect(dialect),
);
assert!(
got.attachments.is_empty(),
"{dialect:?} still drops every image reference"
);
assert!(got.links.is_empty(), "{dialect:?} mints no link either");
}
}
#[test]
fn frontmatter_wikilink_values_name_their_targets() {
let one = Value::String("[[Seismic interpretation]]".to_string());
assert_eq!(
wikilink_targets(&one),
Some(vec!["Seismic interpretation".to_string()])
);
let list = Value::List(vec![
Value::String("[[A]]".to_string()),
Value::String(" [[sub/B.md#frag|shown]] ".to_string()),
]);
assert_eq!(
wikilink_targets(&list),
Some(vec!["A".to_string(), "sub/B".to_string()])
);
let mixed = Value::List(vec![
Value::String("[[A]]".to_string()),
Value::String("plain".to_string()),
]);
assert_eq!(wikilink_targets(&mixed), None);
assert_eq!(
wikilink_targets(&Value::String("see [[A]] there".to_string())),
None,
"a wikilink inside prose is not a typed-edge value"
);
assert_eq!(wikilink_targets(&Value::List(Vec::new())), None);
assert_eq!(wikilink_targets(&Value::Int64(3)), None);
}
#[test]
fn upper_snake_spells_the_edge_type() {
assert_eq!(upper_snake("depends_on"), "DEPENDS_ON");
assert_eq!(upper_snake("see also"), "SEE_ALSO");
assert_eq!(upper_snake("metadata.source"), "METADATA_SOURCE");
assert_eq!(upper_snake("--x--"), "X");
assert_eq!(upper_snake("---"), "");
}
#[test]
fn percent_decode_leaves_a_literal_percent_alone() {
assert_eq!(percent_decode("img/a%20b.png"), "img/a b.png");
assert_eq!(percent_decode("img/50%25.png"), "img/50%.png");
assert_eq!(percent_decode("notes/r%C3%A5data.md"), "notes/rådata.md");
assert_eq!(percent_decode("img/100%.png"), "img/100%.png");
assert_eq!(percent_decode("img/%zz.png"), "img/%zz.png");
assert_eq!(percent_decode("img/a%2.png"), "img/a%2.png");
assert_eq!(percent_decode("img/%FF.png"), "img/%FF.png");
assert!(matches!(percent_decode("img/plain.png"), Cow::Borrowed(_)));
}
#[test]
fn a_rooted_target_is_vault_relative_not_absolute() {
assert_eq!(path_error("/img/x.png", "notes"), None);
assert!(path_error("/../img/x.png", "notes").is_some());
assert_eq!(path_error("../img/x.png", "notes"), None);
assert_eq!(path_error("../../img/x.png", "notes/deep"), None);
assert!(path_error("../../../img/x.png", "notes/deep").is_some());
assert!(path_error("\\\\server\\share\\x.png", "").is_some());
assert!(path_error("D:\\vault\\x.md", "").is_some());
assert_eq!(
path_error("C:notes/x.md", ""),
None,
"no separator, no drive"
);
}
}