use std::ops::Range;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
pub label: Option<String>,
pub target: String,
pub wikilink: bool,
}
impl Link {
pub fn parse(raw: &str) -> Self {
Self::parse_impl(raw, true)
}
pub fn parse_path_only(raw: &str) -> Self {
Self::parse_impl(raw, false)
}
fn parse_impl(raw: &str, wikilink: bool) -> Self {
let raw = raw.trim();
if wikilink && let Some(inner) = raw.strip_prefix("[[").and_then(|r| r.strip_suffix("]]")) {
let (target, label) = match inner.split_once('|') {
Some((target, label)) => (target.trim(), Some(label.trim().to_string())),
None => (inner.trim(), None),
};
return Self {
label,
target: target.to_string(),
wikilink: true,
};
}
if let Some((label, target)) = split_markdown_link(raw) {
return Self {
label: Some(label),
target,
wikilink: false,
};
}
Self {
label: None,
target: raw.to_string(),
wikilink: false,
}
}
pub fn render(&self) -> String {
match (&self.label, self.wikilink) {
(Some(label), true) => format!("[[{}|{label}]]", self.target),
(None, true) => format!("[[{}]]", self.target),
(Some(label), false) => format!("[{label}]({})", emit_target(&self.target)),
(None, false) => self.target.clone(),
}
}
pub fn with_target(&self, target: impl Into<String>) -> Self {
Self {
label: self.label.clone(),
target: target.into(),
wikilink: self.wikilink,
}
}
pub fn with_label(&self, label: impl Into<String>) -> Self {
Self {
label: Some(label.into()),
target: self.target.clone(),
wikilink: self.wikilink,
}
}
pub fn is_external(&self) -> bool {
self.target.contains("://") || self.target.starts_with("mailto:")
}
pub fn locator(&self) -> Option<&str> {
if self.is_external() {
return None;
}
split_locator(&self.target).1
}
pub fn addressed_target(&self) -> &str {
if self.is_external() {
return &self.target;
}
split_locator(&self.target).0
}
pub fn with_path(&self, path: impl Into<String>) -> Self {
self.with_target(join_locator(path, self.locator()))
}
pub fn id_ref(&self) -> Option<IdRef> {
strip_id_scheme(self.addressed_target()).map(parse_id_body)
}
pub fn id_target(&self) -> Option<crate::identity::Id> {
match self.id_ref() {
Some(IdRef::Local(id)) => Some(id),
_ => None,
}
}
pub fn foreign_target(&self) -> Option<(String, crate::identity::Id)> {
match self.id_ref() {
Some(IdRef::Foreign { workspace, id }) => Some((workspace, id)),
_ => None,
}
}
pub fn is_path_target(&self) -> bool {
!self.is_external() && self.id_ref().is_none()
}
}
fn split_markdown_link(raw: &str) -> Option<(String, String)> {
if !raw.starts_with('[') {
return None;
}
let close_bracket = raw.find(']')?;
if !raw[close_bracket..].starts_with("](") {
return None;
}
let label = raw[1..close_bracket].to_string();
let after = &raw[close_bracket + 2..];
let target = if let Some(inner) = after.strip_prefix('<') {
let close_angle = inner.find('>')?;
if inner.get(close_angle + 1..close_angle + 2) != Some(")") {
return None;
}
inner[..close_angle].to_string()
} else {
let close_paren = find_closing_paren(after)?;
after[..close_paren].to_string()
};
Some((label, target))
}
fn find_closing_paren(s: &str) -> Option<usize> {
let mut depth = 0u32;
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => {
if depth == 0 {
return Some(i);
}
depth -= 1;
}
_ => {}
}
}
None
}
fn emit_target(target: &str) -> String {
if target.contains([' ', '(', ')']) {
format!("<{target}>")
} else {
target.to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LinkStyle {
#[default]
MarkdownRoot,
MarkdownRelative,
PlainRoot,
PlainRelative,
}
impl LinkStyle {
pub fn axes(self) -> (Notation, PathStyle) {
match self {
Self::MarkdownRoot => (Notation::Markdown, PathStyle::Root),
Self::MarkdownRelative => (Notation::Markdown, PathStyle::Relative),
Self::PlainRoot => (Notation::Bare, PathStyle::Root),
Self::PlainRelative => (Notation::Bare, PathStyle::Relative),
}
}
pub fn from_axes(notation: Notation, path_style: PathStyle) -> Self {
match (notation, path_style) {
(Notation::Markdown | Notation::Wikilink, PathStyle::Root) => Self::MarkdownRoot,
(Notation::Markdown | Notation::Wikilink, PathStyle::Relative) => {
Self::MarkdownRelative
}
(Notation::Bare, PathStyle::Root) => Self::PlainRoot,
(Notation::Bare, PathStyle::Relative) => Self::PlainRelative,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Notation {
#[default]
Markdown,
Wikilink,
Bare,
}
impl Notation {
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"markdown" => Some(Self::Markdown),
"wikilink" => Some(Self::Wikilink),
"bare" => Some(Self::Bare),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Markdown => "markdown",
Self::Wikilink => "wikilink",
Self::Bare => "bare",
}
}
pub fn wrapper(self) -> Wrapper {
match self {
Self::Wikilink => Wrapper::Wikilink,
Self::Markdown | Self::Bare => Wrapper::Markdown,
}
}
pub fn from_wrapper(wrapper: Wrapper, style: LinkStyle) -> Self {
match wrapper {
Wrapper::Wikilink => Self::Wikilink,
Wrapper::Markdown => style.axes().0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PathStyle {
#[default]
Root,
Relative,
}
impl PathStyle {
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"root" => Some(Self::Root),
"relative" => Some(Self::Relative),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Root => "root",
Self::Relative => "relative",
}
}
}
pub fn format_link(style: LinkStyle, from: &Path, target: &Path, title: &str) -> String {
let canonical = target.to_string_lossy();
match style {
LinkStyle::MarkdownRoot => {
format!("[{title}]({})", emit_target(&format!("/{canonical}")))
}
LinkStyle::MarkdownRelative => {
let rel = relative(from.parent().unwrap_or(Path::new("")), target);
format!("[{title}]({})", emit_target(&rel))
}
LinkStyle::PlainRoot => format!("/{canonical}"),
LinkStyle::PlainRelative => relative(from.parent().unwrap_or(Path::new("")), target),
}
}
pub fn path_to_title(path: &Path) -> String {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
stem.split(['_', '-', ' '])
.filter(|w| !w.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn slug(title: &str) -> String {
let mut out = String::with_capacity(title.len());
let mut pending_dash = false;
for ch in title.chars() {
if ch.is_alphanumeric() {
if pending_dash {
out.push('-');
pending_dash = false;
}
out.extend(ch.to_lowercase());
} else if ch.is_whitespace() || matches!(ch, '-' | '_' | '/') {
pending_dash = !out.is_empty();
}
}
if out.is_empty() {
"untitled".to_string()
} else {
out
}
}
pub const LOCATOR_SEPARATOR: char = '#';
pub fn split_locator(target: &str) -> (&str, Option<&str>) {
match target.split_once(LOCATOR_SEPARATOR) {
Some((doc, locator)) if !doc.is_empty() => (doc, Some(locator)),
_ => (target, None),
}
}
pub fn join_locator(target: impl Into<String>, locator: Option<&str>) -> String {
let mut target = target.into();
if let Some(locator) = locator {
target.push(LOCATOR_SEPARATOR);
target.push_str(locator);
}
target
}
pub const ID_SCHEME: &str = "id:";
pub const LEGACY_ID_SCHEME: &str = "colophon:";
pub fn strip_id_scheme(target: &str) -> Option<&str> {
target
.strip_prefix(ID_SCHEME)
.or_else(|| target.strip_prefix(LEGACY_ID_SCHEME))
}
pub fn id_target(id: &crate::identity::Id) -> String {
format!("{ID_SCHEME}{id}")
}
pub const WORKSPACE_SEPARATOR: char = '/';
pub fn foreign_id_target(workspace: &str, id: &crate::identity::Id) -> String {
format!("{ID_SCHEME}{workspace}{WORKSPACE_SEPARATOR}{id}")
}
pub fn is_valid_workspace_id(name: &str) -> bool {
!name.is_empty()
&& !name
.chars()
.any(|c| c == WORKSPACE_SEPARATOR || c == ':' || c.is_whitespace())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdRef {
Local(crate::identity::Id),
Foreign {
workspace: String,
id: crate::identity::Id,
},
Malformed,
}
fn parse_id_body(body: &str) -> IdRef {
let Some((workspace, id)) = body.split_once(WORKSPACE_SEPARATOR) else {
return if body.is_empty() {
IdRef::Malformed
} else {
IdRef::Local(crate::identity::Id(body.to_string()))
};
};
if workspace.is_empty() || id.is_empty() || id.contains(WORKSPACE_SEPARATOR) {
return IdRef::Malformed;
}
IdRef::Foreign {
workspace: workspace.to_string(),
id: crate::identity::Id(id.to_string()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Wrapper {
#[default]
Markdown,
Wikilink,
}
impl Wrapper {
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"markdown" => Some(Self::Markdown),
"wikilink" => Some(Self::Wikilink),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Markdown => "markdown",
Self::Wikilink => "wikilink",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Addressing {
#[default]
Path,
Id,
Alias,
}
impl Addressing {
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"path" => Some(Self::Path),
"id" => Some(Self::Id),
"alias" => Some(Self::Alias),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Path => "path",
Self::Id => "id",
Self::Alias => "alias",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReferenceStyle {
pub wrapper: Wrapper,
pub addressing: Addressing,
pub label: bool,
pub path_style: LinkStyle,
}
impl Default for ReferenceStyle {
fn default() -> Self {
Self {
wrapper: Wrapper::Markdown,
addressing: Addressing::Path,
label: false,
path_style: LinkStyle::default(),
}
}
}
impl ReferenceStyle {
pub fn normalized(mut self) -> Self {
if self.addressing == Addressing::Alias {
self.wrapper = Wrapper::Wikilink;
}
self
}
pub fn registers(self) -> bool {
self.addressing == Addressing::Id
}
}
pub fn format_reference(
style: ReferenceStyle,
from: &Path,
to: &Path,
id: Option<&crate::identity::Id>,
title: &str,
) -> String {
let style = style.normalized();
match style.addressing {
Addressing::Id => match id {
Some(id) => wrap(style.wrapper, &id_target(id), title, style.label),
None => format_path(
Wrapper::Markdown,
style.path_style,
from,
to,
title,
style.label,
),
},
Addressing::Alias => wrap_alias(title),
Addressing::Path => format_path(
style.wrapper,
style.path_style,
from,
to,
title,
style.label,
),
}
}
fn format_path(
wrapper: Wrapper,
path_style: LinkStyle,
from: &Path,
to: &Path,
title: &str,
label: bool,
) -> String {
match wrapper {
Wrapper::Markdown => format_link(path_style, from, to, title),
Wrapper::Wikilink => wrap(
Wrapper::Wikilink,
&path_text(path_style, from, to),
title,
label,
),
}
}
pub fn path_text(path_style: LinkStyle, from: &Path, to: &Path) -> String {
match path_style {
LinkStyle::MarkdownRoot | LinkStyle::PlainRoot => format!("/{}", to.to_string_lossy()),
LinkStyle::MarkdownRelative | LinkStyle::PlainRelative => {
relative(from.parent().unwrap_or(Path::new("")), to)
}
}
}
fn wrap(wrapper: Wrapper, target: &str, title: &str, with_label: bool) -> String {
match (wrapper, with_label) {
(Wrapper::Wikilink, true) => format!("[[{target}|{title}]]"),
(Wrapper::Wikilink, false) => format!("[[{target}]]"),
(Wrapper::Markdown, true) => format!("[{title}]({})", emit_target(target)),
(Wrapper::Markdown, false) => emit_target(target),
}
}
fn wrap_alias(title: &str) -> String {
format!("[[{title}]]")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Wikilink {
pub target: String,
pub label: Option<String>,
pub span: Range<usize>,
}
impl Wikilink {
fn from_inner(inner: &str, span: Range<usize>) -> Option<Self> {
let (target, label) = match inner.split_once('|') {
Some((target, label)) => (target.trim(), Some(label.trim().to_string())),
None => (inner.trim(), None),
};
if target.is_empty() {
return None;
}
Some(Self {
target: target.to_string(),
label,
span,
})
}
pub fn render(&self) -> String {
match &self.label {
Some(label) => format!("[[{}|{label}]]", self.target),
None => format!("[[{}]]", self.target),
}
}
pub fn with_target(&self, target: impl Into<String>) -> Self {
Self {
target: target.into(),
label: self.label.clone(),
span: self.span.clone(),
}
}
pub fn id_target(&self) -> Option<crate::identity::Id> {
strip_id_scheme(split_locator(&self.target).0).map(|id| crate::identity::Id(id.to_string()))
}
}
pub fn parse_wikilinks(body: &str) -> Vec<Wikilink> {
let mut out = Vec::new();
let mut base = 0; let mut rest = body;
while let Some(open_rel) = rest.find("[[") {
let open = base + open_rel;
let after_open = open + 2;
let Some(close_rel) = body[after_open..].find("]]") else {
break; };
let close = after_open + close_rel;
if let Some(link) = Wikilink::from_inner(&body[after_open..close], open..close + 2) {
out.push(link);
}
base = close + 2;
rest = &body[base..];
}
out
}
pub fn exclude_code_spans(links: Vec<Wikilink>, code_spans: &[Range<usize>]) -> Vec<Wikilink> {
links
.into_iter()
.filter(|link| {
!code_spans
.iter()
.any(|cs| cs.start < link.span.end && link.span.start < cs.end)
})
.collect()
}
pub fn scan_wikilinks(path: &Path, body: &str) -> Vec<Wikilink> {
match code_spans_for(path, body) {
Some(spans) if !spans.is_empty() => scan_outside_spans(body, &merge_spans(spans)),
_ => parse_wikilinks(body),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BodyLink {
pub link: Link,
pub span: Range<usize>,
}
impl BodyLink {
pub fn id_target(&self) -> Option<crate::identity::Id> {
self.link.id_target()
}
pub fn is_path_target(&self) -> bool {
self.link.is_path_target()
}
}
pub fn scan_body_links(path: &Path, body: &str) -> Vec<BodyLink> {
let mut out: Vec<BodyLink> = scan_wikilinks(path, body)
.into_iter()
.map(|wl| BodyLink {
link: Link {
label: wl.label,
target: wl.target,
wikilink: true,
},
span: wl.span,
})
.collect();
for span in parsed_link_spans(path, body) {
let link = Link::parse(&body[span.clone()]);
if link.label.is_none() || link.wikilink {
continue;
}
if out
.iter()
.any(|b| b.span.start < span.end && span.start < b.span.end)
{
continue;
}
out.push(BodyLink { link, span });
}
out.sort_by_key(|b| b.span.start);
out
}
pub fn parsed_link_spans(path: &Path, body: &str) -> Vec<Range<usize>> {
let Some(format) = crate::content::ContentFormat::from_extension(path) else {
return Vec::new();
};
crate::content::link_spans(body, format).unwrap_or_default()
}
fn merge_spans(mut spans: Vec<Range<usize>>) -> Vec<Range<usize>> {
spans.sort_by_key(|s| s.start);
let mut out: Vec<Range<usize>> = Vec::with_capacity(spans.len());
for span in spans {
match out.last_mut() {
Some(prev) if span.start <= prev.end => prev.end = prev.end.max(span.end),
_ => out.push(span),
}
}
out
}
fn scan_outside_spans(body: &str, code_spans: &[Range<usize>]) -> Vec<Wikilink> {
let mut out = Vec::new();
let mut cursor = 0;
for span in code_spans {
if cursor < span.start {
out.extend(shift_spans(
parse_wikilinks(&body[cursor..span.start]),
cursor,
));
}
cursor = cursor.max(span.end);
}
if cursor < body.len() {
out.extend(shift_spans(parse_wikilinks(&body[cursor..]), cursor));
}
out
}
fn shift_spans(links: Vec<Wikilink>, offset: usize) -> Vec<Wikilink> {
links
.into_iter()
.map(|link| Wikilink {
span: link.span.start + offset..link.span.end + offset,
..link
})
.collect()
}
fn code_spans_for(path: &Path, body: &str) -> Option<Vec<Range<usize>>> {
let format = crate::content::ContentFormat::from_extension(path)?;
crate::content::code_spans(body, format).ok()
}
pub fn normalize(path: impl AsRef<Path>) -> PathBuf {
let mut out: Vec<Component> = Vec::new();
for component in path.as_ref().components() {
match component {
Component::CurDir => {}
Component::ParentDir => match out.last() {
Some(Component::Normal(_)) => {
out.pop();
}
_ => out.push(component),
},
other => out.push(other),
}
}
out.iter().collect()
}
pub fn escapes_root(path: impl AsRef<Path>) -> bool {
matches!(
normalize(path).components().next(),
Some(Component::ParentDir | Component::RootDir | Component::Prefix(_))
)
}
pub fn resolve(doc: &Path, target: &str) -> PathBuf {
let (target, _) = split_locator(target);
if let Some(from_root) = target.strip_prefix('/') {
return normalize(from_root);
}
let dir = doc.parent().unwrap_or(Path::new(""));
normalize(dir.join(target))
}
pub fn relative(from_dir: &Path, to: &Path) -> String {
let from: Vec<&std::ffi::OsStr> = from_dir.iter().collect();
let to_parts: Vec<&std::ffi::OsStr> = to.iter().collect();
let common = from
.iter()
.zip(to_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let mut parts: Vec<String> = Vec::new();
for _ in common..from.len() {
parts.push("..".to_string());
}
for part in &to_parts[common..] {
parts.push(part.to_string_lossy().into_owned());
}
if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locator_splits_at_the_first_separator_and_round_trips() {
assert_eq!(split_locator("chapter.md"), ("chapter.md", None));
assert_eq!(split_locator("chapter.md#3"), ("chapter.md", Some("3")));
assert_eq!(split_locator("id:abc1234#2-3"), ("id:abc1234", Some("2-3")));
assert_eq!(split_locator("a.md#b#c"), ("a.md", Some("b#c")));
assert_eq!(split_locator("a.md#"), ("a.md", Some("")));
assert_eq!(split_locator("#3"), ("#3", None));
for target in ["chapter.md", "chapter.md#3", "a.md#b#c", "a.md#", "#3"] {
let (doc, locator) = split_locator(target);
assert_eq!(join_locator(doc, locator), target, "round-trip `{target}`");
}
}
#[test]
fn a_locator_does_not_change_which_document_is_named() {
assert_eq!(
resolve(Path::new("bofm/1-ne-1.md"), "1-ne-2.md#5"),
PathBuf::from("bofm/1-ne-2.md")
);
assert_eq!(
resolve(Path::new("a/b.md"), "/vol/c.md#2-3"),
PathBuf::from("vol/c.md")
);
let plain = Link::parse("[1 Nephi 1](id:abc1234)");
let located = Link::parse("[1 Nephi 1:1](id:abc1234#1)");
assert_eq!(located.id_target(), plain.id_target());
assert_eq!(located.id_ref(), plain.id_ref());
assert_eq!(located.locator(), Some("1"));
assert_eq!(located.addressed_target(), "id:abc1234");
assert_eq!(plain.locator(), None);
assert!(!located.is_path_target());
}
#[test]
fn an_external_url_keeps_its_own_fragment() {
let url = Link::parse("[talk](https://example.com/a#p3)");
assert!(url.is_external());
assert_eq!(url.locator(), None);
assert_eq!(url.addressed_target(), "https://example.com/a#p3");
assert_eq!(url.with_path("x.md").render(), "[talk](x.md)");
}
#[test]
fn with_path_preserves_the_locator_but_with_target_does_not() {
let link = Link::parse("[1 Nephi 1:1](../1-ne-1.md#1)");
assert_eq!(
link.with_path("bofm/1-ne-1.md").render(),
"[1 Nephi 1:1](bofm/1-ne-1.md#1)"
);
assert_eq!(
link.with_target("bofm/1-ne-1.md").render(),
"[1 Nephi 1:1](bofm/1-ne-1.md)"
);
let wl = Link::parse("[[1-ne-1.md#1|1 Nephi 1:1]]");
assert_eq!(wl.locator(), Some("1"));
assert_eq!(wl.with_path("x.md").render(), "[[x.md#1|1 Nephi 1:1]]");
}
#[test]
fn slug_makes_readable_stems_and_round_trips_the_common_case() {
assert_eq!(slug("My Great Note"), "my-great-note");
assert_eq!(slug(" Hello, World! "), "hello-world");
assert_eq!(slug("already-a-slug"), "already-a-slug");
assert_eq!(slug("under_scored/and slashed"), "under-scored-and-slashed");
assert_eq!(slug("v1.0 Release"), "v10-release");
assert_eq!(slug("Café Notes"), "café-notes");
assert_eq!(slug("--x--y--"), "x-y");
assert_eq!(slug("!!!"), "untitled");
assert_eq!(slug(""), "untitled");
assert_eq!(
path_to_title(std::path::Path::new("my-great-note.md")),
"My Great Note"
);
}
#[test]
fn parses_labeled_and_bare_links() {
let l = Link::parse("[Design](docs/design.md)");
assert_eq!(l.label.as_deref(), Some("Design"));
assert_eq!(l.target, "docs/design.md");
assert_eq!(l.render(), "[Design](docs/design.md)");
let bare = Link::parse("notes/a.md");
assert_eq!(bare.label, None);
assert_eq!(bare.render(), "notes/a.md");
}
#[test]
fn odd_shapes_fall_back_to_bare() {
for raw in ["[unclosed](x", "no[mid](x)", "[]"] {
assert_eq!(Link::parse(raw).render(), raw);
}
}
#[test]
fn with_target_keeps_the_label() {
let l = Link::parse("[Design](old.md)").with_target("new.md");
assert_eq!(l.render(), "[Design](new.md)");
}
#[test]
fn external_links_are_flagged() {
assert!(Link::parse("https://example.com/x").is_external());
assert!(Link::parse("[me](mailto:a@b.c)").is_external());
assert!(!Link::parse("docs/design.md").is_external());
}
#[test]
fn id_refs_split_into_local_foreign_and_malformed() {
use crate::identity::Id;
let r = |t: &str| Link::parse(t).id_ref();
assert_eq!(r("id:ajp7eq"), Some(IdRef::Local(Id("ajp7eq".into()))));
assert_eq!(
r("id:notes/ajp7eq"),
Some(IdRef::Foreign {
workspace: "notes".into(),
id: Id("ajp7eq".into()),
})
);
assert_eq!(
r("colophon:notes/ajp7eq"),
Some(IdRef::Foreign {
workspace: "notes".into(),
id: Id("ajp7eq".into()),
})
);
for bad in ["id:", "id:/x", "id:ws/", "id:a/b/c"] {
assert_eq!(r(bad), Some(IdRef::Malformed), "{bad}");
}
assert_eq!(r("docs/design.md"), None);
assert_eq!(r("https://example.com/x"), None);
}
#[test]
fn a_foreign_id_is_not_a_local_id() {
let foreign = Link::parse("id:notes/ajp7eq");
assert_eq!(foreign.id_target(), None);
assert_eq!(
foreign.foreign_target(),
Some(("notes".into(), crate::identity::Id("ajp7eq".into())))
);
let local = Link::parse("id:ajp7eq");
assert_eq!(
local.id_target(),
Some(crate::identity::Id("ajp7eq".into()))
);
assert_eq!(local.foreign_target(), None);
}
#[test]
fn only_paths_are_path_targets() {
for path in ["docs/design.md", "/a.md", "../b.md", "My Note"] {
assert!(Link::parse(path).is_path_target(), "{path}");
}
for stable in [
"id:ajp7eq",
"id:notes/ajp7eq",
"colophon:notes/ajp7eq",
"id:a/b/c",
"https://example.com/x",
"mailto:a@b.c",
] {
assert!(!Link::parse(stable).is_path_target(), "{stable}");
}
}
#[test]
fn foreign_targets_round_trip_through_rendering() {
let target = foreign_id_target("notes", &crate::identity::Id("ajp7eq".into()));
assert_eq!(target, "id:notes/ajp7eq");
assert_eq!(
Link::parse(&target).foreign_target(),
Some(("notes".into(), crate::identity::Id("ajp7eq".into())))
);
let wl = Link::parse("[[id:notes/ajp7eq|My Note]]");
assert_eq!(wl.label.as_deref(), Some("My Note"));
assert_eq!(
wl.foreign_target(),
Some(("notes".into(), crate::identity::Id("ajp7eq".into())))
);
assert_eq!(wl.render(), "[[id:notes/ajp7eq|My Note]]");
}
#[test]
fn parses_angle_bracketed_and_absolute_targets() {
let l = Link::parse("[Archived Documents](</Archive/Archived documents.md>)");
assert_eq!(l.label.as_deref(), Some("Archived Documents"));
assert_eq!(l.target, "/Archive/Archived documents.md");
assert_eq!(
l.render(),
"[Archived Documents](</Archive/Archived documents.md>)"
);
let bare = Link::parse("</Creative Writing/Creative Writing.md>");
assert_eq!(bare.target, "</Creative Writing/Creative Writing.md>");
assert_eq!(bare.render(), "</Creative Writing/Creative Writing.md>");
let plain = Link::parse("[Blog](/Blog/Blog.md)");
assert_eq!(plain.target, "/Blog/Blog.md");
assert_eq!(plain.render(), "[Blog](/Blog/Blog.md)");
}
#[test]
fn bare_angle_bracket_value_stays_literal() {
for raw in ["<notes/a.md>", "<https://example.com>", "<a (b) c>", "<>"] {
let l = Link::parse(raw);
assert_eq!(l.label, None);
assert_eq!(l.target, raw, "bare angle-bracket value must be literal");
assert_eq!(l.render(), raw);
}
assert_eq!(Link::parse("[x](<notes/a.md>)").target, "notes/a.md");
}
#[test]
fn markdown_link_split_tolerates_trailing_text_and_balanced_parens() {
let l = Link::parse("[Title](/path.md) trailing junk");
assert_eq!(l.label.as_deref(), Some("Title"));
assert_eq!(l.target, "/path.md");
let l = Link::parse("[Explanation (1.1)](/Archive/Explanation (1.1).md)");
assert_eq!(l.label.as_deref(), Some("Explanation (1.1)"));
assert_eq!(l.target, "/Archive/Explanation (1.1).md");
let l = Link::parse("[File (a (b))](/path/file (a (b)).md)");
assert_eq!(l.label.as_deref(), Some("File (a (b))"));
assert_eq!(l.target, "/path/file (a (b)).md");
let l = Link::parse("[T](/a (1).md) and then some more words");
assert_eq!(l.target, "/a (1).md");
let l = Link::parse("[Notes](</My Notes/x.md>) ignored tail");
assert_eq!(l.target, "/My Notes/x.md");
let unterminated = "[Title](/path.md";
assert_eq!(Link::parse(unterminated).render(), unterminated);
}
#[test]
fn wikilink_opt_out_keeps_bracket_literal_string() {
let ordinary = Link::parse("[[notes/a.md]]");
assert!(ordinary.wikilink);
assert_eq!(ordinary.target, "notes/a.md");
let opted_out = Link::parse_path_only("[[notes/a.md]]");
assert!(!opted_out.wikilink);
assert_eq!(opted_out.label, None);
assert_eq!(opted_out.target, "[[notes/a.md]]");
assert_eq!(opted_out.render(), "[[notes/a.md]]");
let piped = Link::parse_path_only("[[notes/a.md|My Note]]");
assert_eq!(piped.label, None);
assert_eq!(piped.target, "[[notes/a.md|My Note]]");
assert_eq!(
Link::parse_path_only("[Design](docs/design.md)"),
Link::parse("[Design](docs/design.md)")
);
assert_eq!(
Link::parse_path_only("notes/a.md"),
Link::parse("notes/a.md")
);
assert_eq!(
Link::parse_path_only("<notes/a.md>"),
Link::parse("<notes/a.md>")
);
}
#[test]
fn formats_links_in_each_workspace_style() {
let from = Path::new("School/MATH 213/hw.md");
let target = Path::new("School/Archive/MATH 213 files.md");
assert_eq!(
format_link(LinkStyle::MarkdownRoot, from, target, "MATH 213 Files"),
"[MATH 213 Files](</School/Archive/MATH 213 files.md>)"
);
assert_eq!(
format_link(LinkStyle::MarkdownRelative, from, target, "MATH 213 Files"),
"[MATH 213 Files](<../Archive/MATH 213 files.md>)"
);
assert_eq!(
format_link(LinkStyle::PlainRelative, from, target, "ignored"),
"../Archive/MATH 213 files.md"
);
assert_eq!(
format_link(LinkStyle::PlainRoot, from, target, "ignored"),
"/School/Archive/MATH 213 files.md"
);
}
#[test]
fn link_style_axes_round_trip_and_cover_every_combination() {
use Notation::*;
use PathStyle::*;
for notation in [Markdown, Bare] {
for path_style in [Root, Relative] {
let style = LinkStyle::from_axes(notation, path_style);
assert_eq!(style.axes(), (notation, path_style));
}
}
assert_eq!(
LinkStyle::from_axes(Wikilink, Relative),
LinkStyle::MarkdownRelative
);
assert_eq!(
Notation::from_wrapper(Wrapper::Wikilink, LinkStyle::MarkdownRoot),
Wikilink
);
assert_eq!(Notation::from_config_str("bare"), Some(Bare));
assert_eq!(PathStyle::from_config_str("relative"), Some(Relative));
assert_eq!(PathStyle::from_config_str("canonical"), None);
assert_eq!(LinkStyle::default(), LinkStyle::MarkdownRoot);
assert_eq!(
path_to_title(Path::new("Folder/utility_index.md")),
"Utility Index"
);
}
#[test]
fn the_bare_workspace_absolute_style_renders_a_leading_slash() {
let from = Path::new("a/b.md");
let to = Path::new("c/d.md");
assert_eq!(format_link(LinkStyle::PlainRoot, from, to, "D"), "/c/d.md");
}
#[test]
fn resolves_workspace_absolute_paths_from_the_root() {
assert_eq!(
resolve(Path::new("Meta/Meta files.md"), "/Blog/Blog.md"),
PathBuf::from("Blog/Blog.md")
);
assert_eq!(
resolve(Path::new("deep/nested/doc.md"), "/Resume.md"),
PathBuf::from("Resume.md")
);
assert_eq!(
resolve(Path::new("Meta/Meta files.md"), "../Blog/Blog.md"),
PathBuf::from("Blog/Blog.md")
);
}
#[test]
fn normalizes_dot_and_dotdot() {
assert_eq!(normalize("a/./b/../c.md"), PathBuf::from("a/c.md"));
assert_eq!(normalize("../up.md"), PathBuf::from("../up.md"));
assert_eq!(normalize("a/b/../../x.md"), PathBuf::from("x.md"));
}
#[test]
fn resolves_against_the_documents_directory() {
assert_eq!(
resolve(Path::new("docs/index.md"), "../README.md"),
PathBuf::from("README.md")
);
assert_eq!(
resolve(Path::new("README.md"), "docs/design.md"),
PathBuf::from("docs/design.md")
);
}
#[test]
fn scans_bare_and_labeled_wikilinks_with_spans() {
let body = "see [[notes/a.md]] and [[colophon:ajp7eq|My file]] here";
let links = parse_wikilinks(body);
assert_eq!(links.len(), 2);
assert_eq!(links[0].target, "notes/a.md");
assert_eq!(links[0].label, None);
assert_eq!(&body[links[0].span.clone()], "[[notes/a.md]]");
assert_eq!(links[0].id_target(), None);
assert_eq!(links[1].target, "colophon:ajp7eq");
assert_eq!(links[1].label.as_deref(), Some("My file"));
assert_eq!(&body[links[1].span.clone()], "[[colophon:ajp7eq|My file]]");
assert_eq!(
links[1].id_target(),
Some(crate::identity::Id("ajp7eq".into()))
);
}
#[test]
fn wikilink_scan_trims_and_skips_degenerate_shapes() {
let trimmed = parse_wikilinks("x [[ notes/a.md | Label ]] y");
assert_eq!(trimmed[0].target, "notes/a.md");
assert_eq!(trimmed[0].label.as_deref(), Some("Label"));
assert!(parse_wikilinks("nothing [[]] here").is_empty());
assert!(parse_wikilinks("[[ | orphan label ]]").is_empty());
assert!(parse_wikilinks("dangling [[notes/a.md without close").is_empty());
}
#[test]
fn wikilink_render_round_trips_and_retargets() {
let link = &parse_wikilinks("[[old.md|Design]]")[0];
assert_eq!(link.render(), "[[old.md|Design]]");
assert_eq!(link.with_target("new.md").render(), "[[new.md|Design]]");
let bare = &parse_wikilinks("[[old.md]]")[0];
assert_eq!(bare.render(), "[[old.md]]");
}
#[test]
fn exclude_code_spans_drops_only_overlapping_wikilinks() {
let body = "see [[notes/a.md]] and `[[not/a/link]]` too";
let links = parse_wikilinks(body);
assert_eq!(links.len(), 2, "the lexical scanner has no code awareness");
let code_start = body.find('`').unwrap();
let code_end = body.rfind('`').unwrap() + 1;
let code_span = code_start..code_end;
let kept = exclude_code_spans(links, std::slice::from_ref(&code_span));
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].target, "notes/a.md");
}
#[test]
fn relative_walks_up_and_down() {
assert_eq!(
relative(Path::new("docs"), Path::new("README.md")),
"../README.md"
);
assert_eq!(
relative(Path::new(""), Path::new("docs/design.md")),
"docs/design.md"
);
assert_eq!(relative(Path::new("a/b"), Path::new("a/b/c.md")), "c.md");
assert_eq!(relative(Path::new("a/b"), Path::new("a/b")), ".");
}
#[test]
fn parses_and_round_trips_wikilink_scalars_in_metadata() {
let l = Link::parse("[[id:ajp7eqb|My File]]");
assert!(l.wikilink);
assert_eq!(l.label.as_deref(), Some("My File"));
assert_eq!(l.target, "id:ajp7eqb");
assert_eq!(l.id_target(), Some(crate::identity::Id("ajp7eqb".into())));
assert_eq!(l.render(), "[[id:ajp7eqb|My File]]");
let bare = Link::parse("[[notes/a.md]]");
assert!(bare.wikilink);
assert_eq!(bare.label, None);
assert_eq!(bare.render(), "[[notes/a.md]]");
assert_eq!(
l.with_target("id:zzzzzz9").render(),
"[[id:zzzzzz9|My File]]"
);
}
#[test]
fn id_scheme_reads_current_and_legacy_spellings() {
assert_eq!(strip_id_scheme("id:ajp7eqb"), Some("ajp7eqb"));
assert_eq!(strip_id_scheme("colophon:ajp7eqb"), Some("ajp7eqb"));
assert_eq!(strip_id_scheme("notes/a.md"), None);
assert_eq!(
id_target(&crate::identity::Id("ajp7eqb".into())),
"id:ajp7eqb"
);
assert_eq!(
Link::parse("colophon:ajp7eqb").id_target().unwrap().0,
"ajp7eqb"
);
}
#[test]
fn format_reference_renders_each_style() {
let from = Path::new("notes/hw.md");
let to = Path::new("Archive/a.md");
let id = crate::identity::Id("ajp7eqb".into());
let s = |wrapper, addressing, label| ReferenceStyle {
wrapper,
addressing,
label,
path_style: LinkStyle::MarkdownRoot,
};
assert_eq!(
format_reference(
s(Wrapper::Markdown, Addressing::Path, false),
from,
to,
None,
"A"
),
"[A](/Archive/a.md)"
);
assert_eq!(
format_reference(
s(Wrapper::Wikilink, Addressing::Path, false),
from,
to,
None,
"A"
),
"[[/Archive/a.md]]"
);
assert_eq!(
format_reference(
s(Wrapper::Wikilink, Addressing::Path, true),
from,
to,
None,
"A"
),
"[[/Archive/a.md|A]]"
);
assert_eq!(
format_reference(
s(Wrapper::Markdown, Addressing::Id, false),
from,
to,
Some(&id),
"A"
),
"id:ajp7eqb"
);
assert_eq!(
format_reference(
s(Wrapper::Markdown, Addressing::Id, true),
from,
to,
Some(&id),
"A"
),
"[A](id:ajp7eqb)"
);
assert_eq!(
format_reference(
s(Wrapper::Wikilink, Addressing::Id, false),
from,
to,
Some(&id),
"A"
),
"[[id:ajp7eqb]]"
);
assert_eq!(
format_reference(
s(Wrapper::Wikilink, Addressing::Id, true),
from,
to,
Some(&id),
"A"
),
"[[id:ajp7eqb|A]]"
);
assert_eq!(
format_reference(
s(Wrapper::Markdown, Addressing::Alias, false),
from,
to,
None,
"My File"
),
"[[My File]]"
);
assert_eq!(
format_reference(
s(Wrapper::Wikilink, Addressing::Id, true),
from,
to,
None,
"A"
),
"[A](/Archive/a.md)"
);
}
#[test]
fn reference_style_config_round_trips_and_normalizes() {
assert_eq!(
Wrapper::from_config_str("wikilink"),
Some(Wrapper::Wikilink)
);
assert_eq!(
Addressing::from_config_str("alias"),
Some(Addressing::Alias)
);
assert_eq!(Wrapper::Wikilink.as_config_str(), "wikilink");
assert_eq!(Addressing::Id.as_config_str(), "id");
let n = ReferenceStyle {
addressing: Addressing::Alias,
..ReferenceStyle::default()
}
.normalized();
assert_eq!(n.wrapper, Wrapper::Wikilink);
assert!(
ReferenceStyle {
addressing: Addressing::Id,
..ReferenceStyle::default()
}
.registers()
);
assert!(!ReferenceStyle::default().registers());
}
#[test]
fn path_text_takes_the_path_style_shape() {
let from = Path::new("a/b/hw.md");
let to = Path::new("a/c/x.md");
assert_eq!(path_text(LinkStyle::MarkdownRoot, from, to), "/a/c/x.md");
assert_eq!(
path_text(LinkStyle::MarkdownRelative, from, to),
"../c/x.md"
);
assert_eq!(path_text(LinkStyle::PlainRoot, from, to), "/a/c/x.md");
}
mod properties {
use super::*;
use proptest::prelude::*;
fn doc_path() -> impl Strategy<Value = PathBuf> {
(prop::collection::vec("[a-z]{1,3}", 0..3usize), "[a-z]{1,3}").prop_map(
|(dirs, stem)| {
let mut path = PathBuf::new();
for dir in dirs {
path.push(dir);
}
path.push(format!("{stem}.md"));
path
},
)
}
fn messy_path() -> impl Strategy<Value = PathBuf> {
prop::collection::vec(
prop_oneof![Just(".".to_string()), Just("..".to_string()), "[a-z]{1,3}"],
1..5usize,
)
.prop_map(|parts| parts.iter().collect())
}
fn round_tripping_style() -> impl Strategy<Value = LinkStyle> {
prop::sample::select(vec![
LinkStyle::MarkdownRoot,
LinkStyle::MarkdownRelative,
LinkStyle::PlainRoot,
LinkStyle::PlainRelative,
])
}
proptest! {
#[test]
fn a_formatted_link_resolves_back_to_the_document_it_names(
from in doc_path(),
to in doc_path(),
style in round_tripping_style(),
) {
let written = format_link(style, &from, &to, "Label");
let got = resolve(&from, &Link::parse(&written).target);
prop_assert_eq!(
&got,
&to,
"{:?} wrote `{}` in `{}`",
style,
written,
from.display()
);
}
#[test]
fn re_relativizing_a_link_preserves_the_document_it_names(
from in doc_path(),
to in doc_path(),
referent in doc_path(),
) {
let dir = |p: &Path| p.parent().unwrap_or(Path::new("")).to_path_buf();
let written = relative(&dir(&from), &referent);
let rewritten = relative(&dir(&to), &resolve(&from, &written));
prop_assert_eq!(
resolve(&to, &rewritten),
referent,
"`{}` moving {} → {} became `{}`",
written,
from.display(),
to.display(),
rewritten
);
}
#[test]
fn normalizing_a_path_twice_is_normalizing_it_once(path in messy_path()) {
let once = normalize(&path);
prop_assert_eq!(normalize(&once), once.clone(), "on `{}`", path.display());
}
#[test]
fn a_normalized_path_keeps_only_the_climb_it_could_not_cancel(
path in messy_path(),
) {
let normalized = normalize(&path);
let parts: Vec<_> = normalized.components().collect();
let climb = parts
.iter()
.take_while(|c| matches!(c, Component::ParentDir))
.count();
prop_assert!(
parts[climb..]
.iter()
.all(|c| matches!(c, Component::Normal(_) | Component::RootDir)),
"`{}` normalized to `{}`",
path.display(),
normalized.display()
);
}
}
}
}