use crate::resolve::asset_class::{AssetIndex, AssetProvenance};
use crate::resolve::embed_renderer::Sizing;
use crate::resolve::folder_class::FolderIndex;
use crate::resolve::link_class::UrlIndex;
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case", tag = "kind", content = "data")]
pub enum ReferenceKind {
Link { anchor: Option<String> },
Image,
Iframe,
Pdf,
Video,
Audio,
Model,
FolderListing,
FolderIndexIframe,
Transclusion,
Notebook,
Table,
External { url: String },
Anchor,
Ambiguous,
NotFound,
}
pub struct ReferenceContext<'a> {
pub assets: &'a dyn AssetIndex,
pub folders: &'a dyn FolderIndex,
pub urls: &'a dyn UrlIndex,
}
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ResolvedReference {
pub kind: ReferenceKind,
pub target_path: Option<String>,
pub size: Option<Sizing>,
pub provenance: Option<AssetProvenance>,
pub message: Option<String>,
pub candidates: Vec<String>,
pub url: Option<String>,
}
impl ResolvedReference {
pub(crate) fn not_found() -> Self {
ResolvedReference {
kind: ReferenceKind::NotFound,
target_path: None,
size: None,
provenance: None,
message: None,
candidates: Vec::new(),
url: None,
}
}
pub(crate) fn debug_check_invariant(&self) {
let has_path = matches!(
self.kind,
ReferenceKind::Image
| ReferenceKind::Iframe
| ReferenceKind::Pdf
| ReferenceKind::Video
| ReferenceKind::Audio
| ReferenceKind::Model
| ReferenceKind::FolderListing
| ReferenceKind::FolderIndexIframe
| ReferenceKind::Transclusion
| ReferenceKind::Notebook
| ReferenceKind::Table
);
debug_assert_eq!(
has_path,
self.target_path.is_some(),
"target_path presence must match kind: {:?}",
self.kind
);
}
}
fn filename_ext(path: &str) -> String {
let name = path.rsplit('/').next().unwrap_or(path);
match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => ext.to_lowercase(),
_ => String::new(),
}
}
pub fn classify_reference(
inner: &str,
from_source: &str,
is_embed: bool,
ctx: &ReferenceContext,
) -> ResolvedReference {
let inner = inner.trim();
const EXTERNAL_PREFIXES: &[&str] =
&["http://", "https://", "//", "mailto:", "tel:", "data:"];
if EXTERNAL_PREFIXES.iter().any(|p| inner.starts_with(p)) {
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::External { url: inner.to_string() };
r.debug_check_invariant();
return r;
}
if inner.starts_with('#') || inner.starts_with('?') {
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::Anchor;
return r;
}
let (path_part, pothole) = match inner.split_once('|') {
Some((p, rest)) => (p.trim(), Some(rest)),
None => (inner, None),
};
let (path_no_anchor, anchor) = match path_part.split_once('#') {
Some((p, a)) => (p.trim(), Some(a.to_string())),
None => (path_part, None),
};
let size = pothole.and_then(crate::resolve::embed_renderer::Sizing::parse);
if !is_embed {
use crate::resolve::link_class::{classify_link, LinkClass};
return match classify_link(path_no_anchor, from_source, ctx.urls) {
LinkClass::Resolved { url } => {
let full = match &anchor {
Some(a) => format!("{}#{}", url, a),
None => url,
};
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::Link { anchor: anchor.clone() };
r.url = Some(full);
r
}
LinkClass::Mismatch { canonical } => {
let full = match &anchor {
Some(a) => format!("{}#{}", canonical, a),
None => canonical.clone(),
};
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::Link { anchor: anchor.clone() };
r.url = Some(full);
r.message = Some(format!("resolves to canonical URL {}", canonical));
r
}
LinkClass::External => {
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::External { url: path_no_anchor.to_string() };
r
}
LinkClass::Anchor => {
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::Anchor;
r
}
LinkClass::Broken => ResolvedReference::not_found(),
};
}
use crate::resolve::asset_class::{resolve_asset_ref, AssetResolution};
use crate::resolve::ext_kind::{reference_kind_for_ext, ExtKind};
let looks_like_folder = path_no_anchor.ends_with('/');
let folder_rel: Option<String> = if let Some(abs) = path_no_anchor.strip_prefix('/') {
Some(abs.trim_end_matches('/').to_string())
} else if looks_like_folder {
let from_dir = crate::resolve::parent_dir(from_source);
let mut parts: Vec<&str> = if from_dir.is_empty() {
vec![]
} else {
from_dir.split('/').collect()
};
for seg in path_no_anchor.trim_end_matches('/').split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
Some(parts.join("/"))
} else {
None
};
if let Some(folder_rel) = folder_rel {
let is_folder = looks_like_folder || ctx.folders.is_dir(&folder_rel);
if is_folder {
if ctx.folders.dir_has_markdown_index(&folder_rel) {
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::FolderListing;
r.target_path = Some(folder_rel);
r.size = size;
r.debug_check_invariant();
return r;
}
if ctx.folders.dir_has_static_index(&folder_rel).is_some() {
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::FolderIndexIframe;
r.target_path = Some(folder_rel);
r.size = size;
r.debug_check_invariant();
return r;
}
return ResolvedReference::not_found();
}
}
let query_ext_kind = reference_kind_for_ext(&filename_ext(path_no_anchor));
let resolved: Option<(String, AssetProvenance)> =
match resolve_asset_ref(path_no_anchor, from_source, ctx.assets) {
AssetResolution::Resolved { root_rel, provenance } => Some((root_rel, provenance)),
AssetResolution::Ambiguous { candidates, .. } => {
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::Ambiguous;
r.candidates = candidates;
return r;
}
AssetResolution::NotFound
if is_embed && matches!(query_ext_kind, ExtKind::Other) =>
{
let mut hit = None;
for note_ext in ["md", "markdown"] {
match resolve_asset_ref(
&format!("{path_no_anchor}.{note_ext}"),
from_source,
ctx.assets,
) {
AssetResolution::Resolved { root_rel, provenance } => {
hit = Some((root_rel, provenance));
break;
}
AssetResolution::Ambiguous { candidates, .. } => {
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::Ambiguous;
r.candidates = candidates;
return r;
}
AssetResolution::NotFound => {}
}
}
hit
}
AssetResolution::NotFound => None,
};
match resolved {
Some((root_rel, provenance)) => {
let kind = match reference_kind_for_ext(&filename_ext(&root_rel)) {
ExtKind::Image => ReferenceKind::Image,
ExtKind::Iframe => ReferenceKind::Iframe,
ExtKind::Pdf => ReferenceKind::Pdf,
ExtKind::Video => ReferenceKind::Video,
ExtKind::Audio => ReferenceKind::Audio,
ExtKind::Model => ReferenceKind::Model,
ExtKind::Transclusion => ReferenceKind::Transclusion,
ExtKind::Notebook => ReferenceKind::Notebook,
ExtKind::Table => ReferenceKind::Table,
ExtKind::Other => ReferenceKind::Link { anchor: anchor.clone() },
};
let is_link = matches!(kind, ReferenceKind::Link { .. });
let mut r = ResolvedReference::not_found();
r.kind = kind;
r.target_path = if is_link { None } else { Some(root_rel) };
r.size = size;
r.provenance = Some(provenance);
r.debug_check_invariant();
r
}
None => {
if matches!(query_ext_kind, ExtKind::Other) {
let mut r = ResolvedReference::not_found();
r.kind = ReferenceKind::Link { anchor };
r
} else {
ResolvedReference::not_found()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::asset_class::FakeAssetIndex;
use crate::resolve::folder_class::FakeFolderIndex;
use crate::resolve::link_class::FakeUrlIndex;
fn ctx<'a>(
a: &'a FakeAssetIndex,
f: &'a FakeFolderIndex,
u: &'a FakeUrlIndex,
) -> ReferenceContext<'a> {
ReferenceContext { assets: a, folders: f, urls: u }
}
#[test]
fn external_url_is_external() {
let a = FakeAssetIndex::new(&[]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("https://example.com/x", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::External { url: "https://example.com/x".into() });
assert!(r.target_path.is_none());
}
#[test]
fn bare_anchor_is_anchor() {
let a = FakeAssetIndex::new(&[]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("#section", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Anchor);
}
#[test]
fn not_found_has_no_path() {
let r = ResolvedReference::not_found();
assert_eq!(r.kind, ReferenceKind::NotFound);
assert!(r.target_path.is_none());
r.debug_check_invariant();
}
#[test]
fn image_file_resolves_to_image_kind() {
let a = FakeAssetIndex::new(&["assets/photo.png"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("photo.png", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Image);
assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
r.debug_check_invariant();
}
#[test]
fn html_file_resolves_to_iframe_with_size() {
let a = FakeAssetIndex::new(&["widgets/app.html"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("widgets/app.html|800x600", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Iframe);
assert!(matches!(r.size, Some(crate::resolve::embed_renderer::Sizing::Box(_, _))));
}
#[test]
fn ambiguous_file_match_sets_candidates() {
let a = FakeAssetIndex::new(&["a/logo.png", "b/logo.png"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("logo.png", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Ambiguous);
assert_eq!(r.candidates.len(), 2);
}
#[test]
fn folder_with_static_index_is_iframe() {
let a = FakeAssetIndex::new(&[]);
let mut f = FakeFolderIndex::new();
f.dirs.insert("Resources/app".into());
f.static_index.insert("Resources/app".into(), "index.html".into());
let u = FakeUrlIndex::new();
let r = classify_reference("/Resources/app/", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::FolderIndexIframe);
assert_eq!(r.target_path.as_deref(), Some("Resources/app"));
r.debug_check_invariant();
}
#[test]
fn folder_with_markdown_index_is_listing() {
let a = FakeAssetIndex::new(&[]);
let mut f = FakeFolderIndex::new();
f.dirs.insert("news".into());
f.md_index.insert("news".into());
let u = FakeUrlIndex::new();
let r = classify_reference("/news/", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::FolderListing);
r.debug_check_invariant();
}
#[test]
fn absolute_file_embed_resolves_to_image() {
let a = FakeAssetIndex::new(&["assets/photo.png"]);
let f = FakeFolderIndex::new(); let u = FakeUrlIndex::new();
let r = classify_reference("/assets/photo.png", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Image);
assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
r.debug_check_invariant();
}
#[test]
fn trailing_slash_unresolved_folder_is_not_found() {
let a = FakeAssetIndex::new(&[]);
let f = FakeFolderIndex::new(); let u = FakeUrlIndex::new();
let r = classify_reference("/ghost/", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::NotFound);
}
#[test]
fn bare_note_name_is_link() {
let a = FakeAssetIndex::new(&[]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("some-note", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
assert!(r.target_path.is_none());
r.debug_check_invariant();
}
#[test]
fn missing_known_ext_asset_is_not_found() {
let a = FakeAssetIndex::new(&[]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("missing.png", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::NotFound);
}
#[test]
fn non_embed_md_note_resolves_as_link_not_transclusion() {
let a = FakeAssetIndex::new(&["note.md"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::resolving(&[("note.md", "/note/")]);
let r = classify_reference("note.md", "page.md", false, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
assert_eq!(r.url.as_deref(), Some("/note/"));
}
#[test]
fn embed_md_is_still_transclusion() {
let a = FakeAssetIndex::new(&["note.md"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("note.md", "page.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Transclusion);
}
#[test]
fn bare_embed_resolves_markdown_note_as_transclusion() {
let a = FakeAssetIndex::new(&["support-band.md"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("support-band", "index.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Transclusion);
assert_eq!(r.target_path.as_deref(), Some("support-band.md"));
r.debug_check_invariant();
}
#[test]
fn bare_embed_prefers_source_relative_md_note() {
let a = FakeAssetIndex::new(&["support-band.md", "zh-hans/support-band.md"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("support-band", "zh-hans/index.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Transclusion);
assert_eq!(r.target_path.as_deref(), Some("zh-hans/support-band.md"));
r.debug_check_invariant();
}
#[test]
fn bare_embed_resolves_root_note_from_root_source() {
let a = FakeAssetIndex::new(&["support-band.md", "zh-hans/support-band.md"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("support-band", "index.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Transclusion);
assert_eq!(r.target_path.as_deref(), Some("support-band.md"));
r.debug_check_invariant();
}
#[test]
fn bare_embed_path_qualified_note_is_transclusion() {
let a = FakeAssetIndex::new(&["work/daowu.md"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("work/daowu", "index.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Transclusion);
assert_eq!(r.target_path.as_deref(), Some("work/daowu.md"));
}
#[test]
fn bare_embed_unresolved_stays_link() {
let a = FakeAssetIndex::new(&["other.md"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("support-band", "index.md", true, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
assert!(r.target_path.is_none());
}
#[test]
fn bare_link_not_embed_does_not_use_md_fallback() {
let a = FakeAssetIndex::new(&["support-band.md"]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::new();
let r = classify_reference("support-band", "index.md", false, &ctx(&a, &f, &u));
assert_ne!(r.kind, ReferenceKind::Transclusion);
}
#[test]
fn non_embed_link_carries_anchor() {
let a = FakeAssetIndex::new(&[]);
let f = FakeFolderIndex::new();
let u = FakeUrlIndex::resolving(&[("note", "/note/")]);
let r = classify_reference("note#heading", "page.md", false, &ctx(&a, &f, &u));
assert_eq!(r.kind, ReferenceKind::Link { anchor: Some("heading".into()) });
assert_eq!(r.url.as_deref(), Some("/note/#heading"));
}
}