#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::collections::HashMap;
use std::ops::Range;
pub mod code;
mod spanned;
pub use code::strip_code;
pub use spanned::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkKind {
Wikilink,
Embed,
Frontmatter,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawLink {
pub target: String,
pub kind: LinkKind,
pub field: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WikiLink {
pub target: String,
pub heading: Option<String>,
pub alias: Option<String>,
pub embed: bool,
}
pub fn parse_inner(inner: &str, embed: bool) -> Option<WikiLink> {
let target = target_range(inner)?;
let (before_alias, alias) = split_alias(inner);
Some(WikiLink {
target: inner[target].to_string(),
heading: before_alias
.split_once('#')
.map(|(_, h)| h.trim().to_string()),
alias: alias.map(|a| a.trim().to_string()),
embed,
})
}
pub fn target_range(inner: &str) -> Option<Range<usize>> {
let (before_alias, _) = split_alias(inner);
let raw = before_alias
.split_once('#')
.map_or(before_alias, |(t, _)| t);
let target = raw.trim();
if target.is_empty() {
return None;
}
let lead = raw.len() - raw.trim_start().len();
Some(lead..lead + target.len())
}
fn split_alias(inner: &str) -> (&str, Option<&str>) {
match inner.split_once('|') {
Some((t, a)) => (t.strip_suffix('\\').unwrap_or(t), Some(a)),
None => (inner, None),
}
}
pub fn wikilink_targets(body: &str) -> Vec<String> {
body_links(body)
.into_iter()
.map(|link| link.target)
.collect()
}
pub fn body_links(body: &str) -> Vec<RawLink> {
body_links_spanned(body)
.into_iter()
.map(|link| RawLink {
target: link.target,
kind: match link.kind {
BodyLinkKind::Embed => LinkKind::Embed,
BodyLinkKind::Wikilink | BodyLinkKind::Markdown => LinkKind::Wikilink,
},
field: None,
})
.collect()
}
pub const ATTACHMENT_EXTENSIONS: &[&str] = &[
"png", "jpg", "jpeg", "gif", "svg", "webp", "avif", "bmp", "ico", "tiff", "heic", "mp4",
"webm", "ogv", "mov", "mkv", "mp3", "wav", "m4a", "3gp", "flac", "ogg", "oga", "opus", "pdf",
"base", "canvas",
];
pub fn is_vault_link_url(url: &str) -> bool {
if url.len() > 3 && url.ends_with(".md") {
return true;
}
let last = url.rsplit('/').next().unwrap_or(url);
match last.rfind('.') {
Some(0) | None => false,
Some(at) => {
let ext = last[at + 1..].to_lowercase();
ATTACHMENT_EXTENSIONS.contains(&ext.as_str())
}
}
}
pub fn md_link_target(url: &str) -> String {
let decoded = percent_decode(url);
let trimmed = decoded.trim_end_matches(".md").trim_end_matches(".MD");
if trimmed.len() == decoded.len() {
strip_md_ext(&decoded)
} else {
trimmed.to_string()
}
}
pub fn strip_md_ext(s: &str) -> String {
if s.len() >= 3
&& s.is_char_boundary(s.len() - 3)
&& s[s.len() - 3..].eq_ignore_ascii_case(".md")
{
s[..s.len() - 3].to_string()
} else {
s.to_string()
}
}
pub fn frontmatter_links(fm: &yaml_splice::value::Mapping) -> Vec<RawLink> {
use yaml_splice::value::Value;
fn visit(v: &Value, field: &str, out: &mut Vec<RawLink>) {
match v {
Value::Str(s) => {
for l in body_links(s) {
if matches!(l.kind, LinkKind::Wikilink | LinkKind::Embed) {
out.push(RawLink {
target: l.target,
kind: LinkKind::Frontmatter,
field: Some(field.to_string()),
});
}
}
}
Value::Seq(items) => items.iter().for_each(|i| visit(i, field, out)),
Value::Map(m) => m.iter().for_each(|(_, i)| visit(i, field, out)),
_ => {}
}
}
let mut out = Vec::new();
for (key, value) in fm.iter() {
visit(value, key, &mut out);
}
out
}
pub fn frontmatter_paths(fm: &yaml_splice::value::Mapping) -> Vec<RawLink> {
use yaml_splice::value::Value;
fn visit(v: &Value, field: &str, out: &mut Vec<RawLink>) {
match v {
Value::Str(s) => {
if let Some(path) = as_path(s) {
out.push(RawLink {
target: path.to_string(),
kind: LinkKind::Frontmatter,
field: Some(field.to_string()),
});
}
}
Value::Seq(items) => items.iter().for_each(|i| visit(i, field, out)),
Value::Map(m) => m.iter().for_each(|(_, i)| visit(i, field, out)),
_ => {}
}
}
let mut out = Vec::new();
for (key, value) in fm.iter() {
visit(value, key, &mut out);
}
out
}
fn as_path(s: &str) -> Option<&str> {
let s = s.trim();
if s.is_empty() || s.contains("[[") || s.contains("://") || s.contains('\n') {
return None;
}
has_file_extension(s).then_some(s)
}
fn has_file_extension(s: &str) -> bool {
let name = s.rsplit('/').next().unwrap_or(s);
let Some((stem, ext)) = name.rsplit_once('.') else {
return false;
};
!stem.is_empty()
&& (1..=8).contains(&ext.len())
&& ext.bytes().all(|b| b.is_ascii_alphanumeric())
&& ext.bytes().any(|b| b.is_ascii_alphabetic())
}
pub fn may_hold_frontmatter_paths(yaml: &str) -> bool {
yaml.split([
'\n', '\r', ' ', '\t', ':', '[', ']', '{', '}', ',', '"', '\'', '#',
])
.any(has_file_extension)
}
pub fn display_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let mut i = 0usize;
while i < chars.len() {
if chars[i] == '!'
&& chars.get(i + 1) == Some(&'[')
&& chars.get(i + 2) == Some(&'[')
&& let Some((inner, next)) = take_link(&chars, i + 1)
{
if let Some(l) = parse_inner(&inner, true) {
out.push_str(shown(&l));
}
i = next;
continue;
}
if chars[i] == '['
&& chars.get(i + 1) == Some(&'[')
&& let Some((inner, next)) = take_link(&chars, i)
{
if let Some(l) = parse_inner(&inner, false) {
out.push_str(shown(&l));
}
i = next;
continue;
}
out.push(chars[i]);
i += 1;
}
out
}
fn shown(link: &WikiLink) -> &str {
let alias = link.alias.as_deref().map(|alias| {
if !link.embed {
return alias;
}
let (rest, last) = alias.rsplit_once('|').unwrap_or(("", alias));
if is_size(last.trim()) {
rest.trim()
} else {
alias
}
});
alias.filter(|a| !a.is_empty()).unwrap_or(&link.target)
}
fn is_size(s: &str) -> bool {
let digits = |p: &str| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit());
match s.split_once('x') {
Some((w, h)) => digits(w) && digits(h),
None => digits(s),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypedRef {
pub kind: String,
pub slug: String,
pub alias: Option<String>,
}
pub fn parse_typed_ref(value: &str) -> Option<TypedRef> {
let inner = value.strip_prefix("[[")?.strip_suffix("]]")?;
if inner.contains(['[', ']', '\n', '\r']) {
return None;
}
let (target, alias) = split_alias(inner);
let (kind, slug) = target.split_once('/')?;
let word = |s: &str| !s.is_empty() && !s.chars().any(char::is_whitespace);
if !word(kind) || !slug.split('/').all(word) {
return None;
}
Some(TypedRef {
kind: kind.to_string(),
slug: slug.to_string(),
alias: alias.map(str::to_string),
})
}
fn take_link(chars: &[char], start: usize) -> Option<(String, usize)> {
let mut j = start + 2;
let mut inner = String::new();
while j + 1 < chars.len() {
if chars[j] == ']' && chars[j + 1] == ']' {
return Some((inner, j + 2));
}
if chars[j] == '[' || chars[j] == ']' {
return None;
}
inner.push(chars[j]);
j += 1;
}
None
}
pub fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let hex = std::str::from_utf8(&b[i + 1..i + 3]).ok();
if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(v);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
}
#[derive(Debug, Clone, Default)]
pub struct LinkIndex {
by_key: HashMap<String, String>,
ambiguous: HashMap<String, Vec<String>>,
pub collisions: usize,
}
pub fn prefer_in_folder<'a>(field: &'a [String], from: &str) -> Option<&'a str> {
let first = field.first()?;
if field.len() == 1 {
return Some(first);
}
let folder = source_folder(from);
let prefix = format!("{folder}/");
if !folder.is_empty()
&& let Some(hit) = field.iter().find(|p| p.to_lowercase().starts_with(&prefix))
{
return Some(hit);
}
Some(first)
}
pub fn join_relative(target: &str, from: &str) -> Option<String> {
let t = target.trim().replace('\\', "/");
if !(t.starts_with("./") || t.starts_with("../")) {
return None;
}
let from = from.trim().replace('\\', "/");
let mut segs: Vec<&str> = match from.rfind('/') {
Some(at) => from[..at].split('/').filter(|s| !s.is_empty()).collect(),
None => Vec::new(),
};
for seg in t.split('/') {
match seg {
"" | "." => {}
".." => {
segs.pop()?;
}
_ => segs.push(seg),
}
}
Some(segs.join("/"))
}
fn source_folder(from: &str) -> String {
let normalized = from.trim().replace('\\', "/");
match normalized.rfind('/') {
Some(at) => normalized[..at].to_lowercase(),
None => String::new(),
}
}
pub fn normalize_target(raw: &str) -> String {
let mut t = raw.trim().replace('\\', "/");
while let Some(rest) = t.strip_prefix("./") {
t = rest.to_string();
}
while t.contains("//") {
t = t.replace("//", "/");
}
t = percent_decode(&t);
strip_md_ext(&t).to_lowercase()
}
impl LinkIndex {
pub fn build(paths: &[String]) -> LinkIndex {
let mut sorted: Vec<&String> = paths.iter().collect();
sorted.sort_by(|a, b| {
a.chars()
.count()
.cmp(&b.chars().count())
.then_with(|| slug_collate::compare_paths(a, b))
});
let mut by_key: HashMap<String, String> = HashMap::new();
let mut ambiguous: HashMap<String, Vec<String>> = HashMap::new();
let mut collisions = 0usize;
for path in sorted {
for suffix in suffixes(path) {
let key = normalize_target(&suffix);
match by_key.get(&key).cloned() {
Some(existing) => {
if existing != *path {
collisions += 1;
let field = ambiguous.entry(key).or_insert_with(|| vec![existing]);
if !field.iter().any(|p| p == path) {
field.push(path.clone());
}
}
}
None => {
by_key.insert(key, path.clone());
}
}
}
}
LinkIndex {
by_key,
ambiguous,
collisions,
}
}
pub fn resolve(&self, target: &str) -> Option<&str> {
self.resolve_from(target, "")
}
pub fn resolve_from(&self, target: &str, from: &str) -> Option<&str> {
if let Some(joined) = join_relative(target, from) {
let key = normalize_target(&joined);
if let Some(field) = self.candidates(&key) {
if let Some(hit) = field.iter().find(|p| normalize_target(p) == key) {
return Some(hit);
}
}
}
let key = normalize_target(target);
let field = match self.candidates(&key) {
Some(field) => field,
None => {
let slash = key.rfind('/')?;
if slash == 0 {
return None;
}
self.candidates(&key[slash + 1..])?
}
};
prefer_in_folder(field, from)
}
fn candidates(&self, key: &str) -> Option<&[String]> {
if let Some(field) = self.ambiguous.get(key) {
return Some(field);
}
self.by_key.get(key).map(std::slice::from_ref)
}
pub fn len(&self) -> usize {
self.by_key.len()
}
pub fn is_empty(&self) -> bool {
self.by_key.is_empty()
}
}
pub fn suffixes(path_no_ext: &str) -> Vec<String> {
let segs: Vec<&str> = path_no_ext.split('/').collect();
(0..segs.len()).rev().map(|i| segs[i..].join("/")).collect()
}
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefer_in_folder_matches_whole_folder_names() {
let field = vec!["notes2/a.md".to_string(), "notes/a.md".to_string()];
assert_eq!(prefer_in_folder(&field, "notes/x.md"), Some("notes/a.md"));
assert_eq!(prefer_in_folder(&field, "Notes/x.md"), Some("notes/a.md"));
let only_sibling = vec!["z/a.md".to_string(), "notes2/a.md".to_string()];
assert_eq!(
prefer_in_folder(&only_sibling, "notes/x.md"),
Some("z/a.md")
);
}
#[test]
fn parses_wikilinks_embeds_headings_and_aliases() {
let links = body_links("See [[Target]] and ![[img.png]] and [[a#h|Alias]].");
assert_eq!(
links,
vec![
RawLink {
target: "Target".into(),
kind: LinkKind::Wikilink,
field: None
},
RawLink {
target: "img.png".into(),
kind: LinkKind::Embed,
field: None
},
RawLink {
target: "a".into(),
kind: LinkKind::Wikilink,
field: None
},
]
);
}
#[test]
fn body_links_come_back_in_the_order_they_appear() {
let targets: Vec<_> = body_links("[a](x.md) [[y]] [b](z.md)")
.into_iter()
.map(|l| l.target)
.collect();
assert_eq!(targets, vec!["x", "y", "z"]);
}
#[test]
fn links_inside_code_are_not_harvested() {
let body = "real [[Yes]]\n\n```\nfake [[No]]\n```\n\ninline `[[AlsoNo]]` done\n";
let targets: Vec<_> = body_links(body).into_iter().map(|l| l.target).collect();
assert_eq!(targets, vec!["Yes"]);
}
#[test]
fn relative_markdown_links_count_absolute_urls_do_not() {
let links = body_links("[a](Notes/x.md) [b](https://example.com/y.md) [c](<sp ace.md>)");
let targets: Vec<_> = links.into_iter().map(|l| l.target).collect();
assert_eq!(targets, vec!["Notes/x"]);
}
#[test]
fn percent_encoded_markdown_links_are_decoded() {
let links = body_links("[a](Notes/My%20Note.md)");
assert_eq!(links[0].target, "Notes/My Note");
}
#[test]
fn target_range_matches_the_parsed_target() {
for inner in [
"Note",
" Note |a",
"Note\\|a",
"Note \\|a",
"Note#H\\|a",
"Note\\\\|a",
"Note\\",
"a#b|c#d",
] {
let range = target_range(inner).expect(inner);
assert_eq!(
&inner[range],
parse_inner(inner, false).unwrap().target,
"{inner}"
);
}
assert_eq!(target_range("Note\\|a"), Some(0..4));
assert_eq!(target_range("\\|a"), None);
assert_eq!(target_range("#H"), None);
}
#[test]
fn empty_targets_are_dropped() {
assert!(body_links("[[]] [[ ]] [[|alias]]").is_empty());
}
#[test]
fn frontmatter_links_are_tagged_with_their_key() {
let fm =
yaml_splice::parse::parse_mapping("rel: '[[Other]]'\nlist:\n - '[[Two]]'\n").unwrap();
let links = frontmatter_links(&fm);
assert_eq!(links.len(), 2);
assert_eq!(links[0].field.as_deref(), Some("rel"));
assert_eq!(links[1].target, "Two");
assert_eq!(links[1].field.as_deref(), Some("list"));
}
#[test]
fn frontmatter_paths_keep_values_with_an_extension() {
let fm = yaml_splice::parse::parse_mapping(
"cover: img/a.png\nfiles:\n - docs/b.pdf\n - plain words\ntitle: v1.2\nurl: https://x.org/c.png\nup: '[[Note]]'\nn: 3\n",
)
.unwrap();
let links = frontmatter_paths(&fm);
let got: Vec<(&str, &str)> = links
.iter()
.map(|l| (l.field.as_deref().unwrap(), l.target.as_str()))
.collect();
assert_eq!(got, vec![("cover", "img/a.png"), ("files", "docs/b.pdf")]);
assert!(links.iter().all(|l| l.kind == LinkKind::Frontmatter));
}
#[test]
fn the_path_pre_test_matches_what_the_parse_keeps() {
assert!(may_hold_frontmatter_paths("cover: img/a.png\n"));
assert!(may_hold_frontmatter_paths("files:\n - \"docs/b.pdf\"\n"));
assert!(may_hold_frontmatter_paths("tags: [a, b.jpg]\n"));
assert!(may_hold_frontmatter_paths("files: [a.png, notes]\n"));
assert!(may_hold_frontmatter_paths("cover: img/a.png # hero\n"));
assert!(may_hold_frontmatter_paths("m: {cover: a.png, n: 1}\n"));
assert!(!may_hold_frontmatter_paths("title: Hello\nn: 3\nv: 1.2\n"));
}
#[test]
fn display_text_replaces_links_with_their_alias_or_target() {
assert_eq!(
display_text("See [[Target|the thing]] and ![[img.png]] and [[Plain]]."),
"See the thing and img.png and Plain."
);
}
#[test]
fn suffix_index_resolves_every_path_suffix() {
let idx = LinkIndex::build(&["notes/muscles/brachialis.md".to_string()]);
for t in [
"brachialis",
"Muscles/brachialis",
"notes/muscles/brachialis",
] {
assert_eq!(
idx.resolve(t),
Some("notes/muscles/brachialis.md"),
"for {t}"
);
}
}
#[test]
fn resolution_is_case_insensitive_and_extension_tolerant() {
let idx = LinkIndex::build(&["Notes/Alpha.md".to_string(), "_att/photo.jpeg".to_string()]);
assert_eq!(idx.resolve("alpha"), Some("Notes/Alpha.md"));
assert_eq!(idx.resolve("ALPHA.md"), Some("Notes/Alpha.md"));
assert_eq!(idx.resolve("photo.jpeg"), Some("_att/photo.jpeg"));
assert_eq!(idx.resolve("photo"), None);
}
#[test]
fn shortest_path_wins_a_collision() {
let idx = LinkIndex::build(&[
"Deep/Nested/Folder/note.md".to_string(),
"note.md".to_string(),
]);
assert_eq!(idx.resolve("note"), Some("note.md"));
assert!(idx.collisions > 0);
assert_eq!(
idx.resolve("Folder/note"),
Some("Deep/Nested/Folder/note.md")
);
}
#[test]
fn a_pathed_miss_falls_back_to_the_basename() {
let idx = LinkIndex::build(&["notes/groups/glutes.md".to_string()]);
assert_eq!(
idx.resolve("muscle-groups/glutes"),
Some("notes/groups/glutes.md")
);
}
#[test]
fn an_unresolvable_target_is_none_not_a_panic() {
let idx = LinkIndex::build(&["a.md".to_string()]);
assert_eq!(idx.resolve("nothing/here"), None);
assert_eq!(idx.resolve(""), None);
}
#[test]
fn backslashes_and_dot_slash_normalise() {
let idx = LinkIndex::build(&["Notes/Sub/x.md".to_string()]);
assert_eq!(idx.resolve("Notes\\Sub\\x"), Some("Notes/Sub/x.md"));
assert_eq!(idx.resolve("./Notes/Sub/x.md"), Some("Notes/Sub/x.md"));
}
#[test]
fn unicode_targets_resolve() {
let idx = LinkIndex::build(&["Заметки/Привет.md".to_string()]);
assert_eq!(idx.resolve("привет"), Some("Заметки/Привет.md"));
}
}