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 id_target(&self) -> Option<crate::identity::Id> {
strip_id_scheme(&self.target).map(|id| crate::identity::Id(id.to_string()))
}
}
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,
MarkdownCanonical,
PlainRoot,
PlainRelative,
PlainCanonical,
}
impl LinkStyle {
pub fn axes(self) -> (Notation, PathStyle) {
match self {
Self::MarkdownRoot => (Notation::Markdown, PathStyle::Root),
Self::MarkdownRelative => (Notation::Markdown, PathStyle::Relative),
Self::MarkdownCanonical => (Notation::Markdown, PathStyle::Canonical),
Self::PlainRoot => (Notation::Bare, PathStyle::Root),
Self::PlainRelative => (Notation::Bare, PathStyle::Relative),
Self::PlainCanonical => (Notation::Bare, PathStyle::Canonical),
}
}
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::Markdown | Notation::Wikilink, PathStyle::Canonical) => {
Self::MarkdownCanonical
}
(Notation::Bare, PathStyle::Root) => Self::PlainRoot,
(Notation::Bare, PathStyle::Relative) => Self::PlainRelative,
(Notation::Bare, PathStyle::Canonical) => Self::PlainCanonical,
}
}
}
#[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,
Canonical,
}
impl PathStyle {
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"root" => Some(Self::Root),
"relative" => Some(Self::Relative),
"canonical" => Some(Self::Canonical),
_ => None,
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Root => "root",
Self::Relative => "relative",
Self::Canonical => "canonical",
}
}
}
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::MarkdownCanonical => format!("[{title}]({})", emit_target(&canonical)),
LinkStyle::PlainRoot => format!("/{canonical}"),
LinkStyle::PlainRelative => relative(from.parent().unwrap_or(Path::new("")), target),
LinkStyle::PlainCanonical => canonical.into_owned(),
}
}
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 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}")
}
#[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)
}
LinkStyle::MarkdownCanonical | LinkStyle::PlainCanonical => {
to.to_string_lossy().into_owned()
}
}
}
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(&self.target).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 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 markdown_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
}
fn markdown_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 {
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 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 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::PlainCanonical, from, target, "ignored"),
"School/Archive/MATH 213 files.md"
);
}
#[test]
fn link_style_axes_round_trip_and_cover_all_six_combinations() {
use Notation::*;
use PathStyle::*;
for notation in [Markdown, Bare] {
for path_style in [Root, Relative, Canonical] {
let style = LinkStyle::from_axes(notation, path_style);
assert_eq!(style.axes(), (notation, path_style));
}
}
assert_eq!(
LinkStyle::from_axes(Wikilink, Canonical),
LinkStyle::MarkdownCanonical
);
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("canonical"), Some(Canonical));
assert_eq!(LinkStyle::default(), LinkStyle::MarkdownRoot);
assert_eq!(
path_to_title(Path::new("Folder/utility_index.md")),
"Utility Index"
);
}
#[test]
fn the_two_new_link_styles_render_bracketed_canonical_and_bare_root() {
let from = Path::new("a/b.md");
let to = Path::new("c/d.md");
assert_eq!(
format_link(LinkStyle::MarkdownCanonical, from, to, "D"),
"[D](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::PlainCanonical, from, to), "a/c/x.md");
}
}