1use crate::resolve::asset_class::{AssetIndex, AssetProvenance};
8use crate::resolve::embed_renderer::Sizing;
9use crate::resolve::folder_class::FolderIndex;
10use crate::resolve::link_class::UrlIndex;
11
12#[cfg_attr(feature = "specta", derive(specta::Type))]
13#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "kebab-case", tag = "kind", content = "data")]
15pub enum ReferenceKind {
16 Link { anchor: Option<String> },
17 Image,
18 Iframe,
19 Pdf,
20 Video,
21 Audio,
22 Model,
23 FolderListing,
24 FolderIndexIframe,
25 Transclusion,
26 Notebook,
27 Table,
28 External { url: String },
29 Anchor,
30 Ambiguous,
31 NotFound,
32}
33
34pub struct ReferenceContext<'a> {
37 pub assets: &'a dyn AssetIndex,
38 pub folders: &'a dyn FolderIndex,
39 pub urls: &'a dyn UrlIndex,
42}
43
44#[cfg_attr(feature = "specta", derive(specta::Type))]
45#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
46pub struct ResolvedReference {
47 pub kind: ReferenceKind,
48 pub target_path: Option<String>,
51 pub size: Option<Sizing>,
52 pub provenance: Option<AssetProvenance>,
53 pub message: Option<String>,
55 pub candidates: Vec<String>,
57 pub url: Option<String>,
60}
61
62impl ResolvedReference {
63 pub(crate) fn not_found() -> Self {
64 ResolvedReference {
65 kind: ReferenceKind::NotFound,
66 target_path: None,
67 size: None,
68 provenance: None,
69 message: None,
70 candidates: Vec::new(),
71 url: None,
72 }
73 }
74 pub(crate) fn debug_check_invariant(&self) {
76 let has_path = matches!(
77 self.kind,
78 ReferenceKind::Image
79 | ReferenceKind::Iframe
80 | ReferenceKind::Pdf
81 | ReferenceKind::Video
82 | ReferenceKind::Audio
83 | ReferenceKind::Model
84 | ReferenceKind::FolderListing
85 | ReferenceKind::FolderIndexIframe
86 | ReferenceKind::Transclusion
87 | ReferenceKind::Notebook
88 | ReferenceKind::Table
89 );
90 debug_assert_eq!(
91 has_path,
92 self.target_path.is_some(),
93 "target_path presence must match kind: {:?}",
94 self.kind
95 );
96 }
97}
98
99pub fn classify_reference(
102 inner: &str,
103 from_source: &str,
104 is_embed: bool,
105 ctx: &ReferenceContext,
106) -> ResolvedReference {
107 let inner = inner.trim();
108
109 const EXTERNAL_PREFIXES: &[&str] =
111 &["http://", "https://", "//", "mailto:", "tel:", "data:"];
112 if EXTERNAL_PREFIXES.iter().any(|p| inner.starts_with(p)) {
113 let mut r = ResolvedReference::not_found();
114 r.kind = ReferenceKind::External { url: inner.to_string() };
115 r.debug_check_invariant();
116 return r;
117 }
118 if inner.starts_with('#') || inner.starts_with('?') {
120 let mut r = ResolvedReference::not_found();
121 r.kind = ReferenceKind::Anchor;
122 return r;
123 }
124
125 let (path_part, pothole) = match inner.split_once('|') {
127 Some((p, rest)) => (p.trim(), Some(rest)),
128 None => (inner, None),
129 };
130 let (path_no_anchor, anchor) = match path_part.split_once('#') {
131 Some((p, a)) => (p.trim(), Some(a.to_string())),
132 None => (path_part, None),
133 };
134 let size = pothole.and_then(crate::resolve::embed_renderer::Sizing::parse);
135
136 if !is_embed {
143 use crate::resolve::link_class::{classify_link, LinkClass};
144 return match classify_link(path_no_anchor, from_source, ctx.urls) {
145 LinkClass::Resolved { url } => {
146 let full = match &anchor {
147 Some(a) => format!("{}#{}", url, a),
148 None => url,
149 };
150 let mut r = ResolvedReference::not_found();
151 r.kind = ReferenceKind::Link { anchor: anchor.clone() };
152 r.url = Some(full);
153 r
154 }
155 LinkClass::Mismatch { canonical } => {
156 let full = match &anchor {
160 Some(a) => format!("{}#{}", canonical, a),
161 None => canonical.clone(),
162 };
163 let mut r = ResolvedReference::not_found();
164 r.kind = ReferenceKind::Link { anchor: anchor.clone() };
165 r.url = Some(full);
166 r.message = Some(format!("resolves to canonical URL {}", canonical));
167 r
168 }
169 LinkClass::External => {
170 let mut r = ResolvedReference::not_found();
171 r.kind = ReferenceKind::External { url: path_no_anchor.to_string() };
172 r
173 }
174 LinkClass::Anchor => {
175 let mut r = ResolvedReference::not_found();
176 r.kind = ReferenceKind::Anchor;
177 r
178 }
179 LinkClass::Broken => ResolvedReference::not_found(),
181 };
182 }
183
184 use crate::resolve::asset_class::{resolve_asset_ref, AssetResolution};
185 use crate::resolve::ext_kind::{reference_kind_for_ext, ExtKind};
186
187 let looks_like_folder = path_no_anchor.ends_with('/');
189 let folder_rel: Option<String> = if let Some(abs) = path_no_anchor.strip_prefix('/') {
190 Some(abs.trim_end_matches('/').to_string())
191 } else if looks_like_folder {
192 let from_dir = crate::resolve::parent_dir(from_source);
194 let mut parts: Vec<&str> = if from_dir.is_empty() {
195 vec![]
196 } else {
197 from_dir.split('/').collect()
198 };
199 for seg in path_no_anchor.trim_end_matches('/').split('/') {
200 match seg {
201 "" | "." => {}
202 ".." => {
203 parts.pop();
204 }
205 s => parts.push(s),
206 }
207 }
208 Some(parts.join("/"))
209 } else {
210 None
211 };
212 if let Some(folder_rel) = folder_rel {
213 let is_folder = looks_like_folder || ctx.folders.is_dir(&folder_rel);
219 if is_folder {
220 if ctx.folders.dir_has_markdown_index(&folder_rel) {
221 let mut r = ResolvedReference::not_found();
222 r.kind = ReferenceKind::FolderListing;
223 r.target_path = Some(folder_rel);
224 r.size = size;
225 r.debug_check_invariant();
226 return r;
227 }
228 if ctx.folders.dir_has_static_index(&folder_rel).is_some() {
229 let mut r = ResolvedReference::not_found();
230 r.kind = ReferenceKind::FolderIndexIframe;
231 r.target_path = Some(folder_rel);
232 r.size = size;
233 r.debug_check_invariant();
234 return r;
235 }
236 return ResolvedReference::not_found();
240 }
241 }
242
243 let ext = path_no_anchor.rsplit('.').next().unwrap_or("").to_lowercase();
245 let ext_kind = reference_kind_for_ext(&ext);
246 match resolve_asset_ref(path_no_anchor, from_source, ctx.assets) {
247 AssetResolution::Resolved { root_rel, provenance } => {
248 let kind = match ext_kind {
249 ExtKind::Image => ReferenceKind::Image,
250 ExtKind::Iframe => ReferenceKind::Iframe,
251 ExtKind::Pdf => ReferenceKind::Pdf,
252 ExtKind::Video => ReferenceKind::Video,
253 ExtKind::Audio => ReferenceKind::Audio,
254 ExtKind::Model => ReferenceKind::Model,
255 ExtKind::Transclusion => ReferenceKind::Transclusion,
256 ExtKind::Notebook => ReferenceKind::Notebook,
257 ExtKind::Table => ReferenceKind::Table,
258 ExtKind::Other => ReferenceKind::Link { anchor: anchor.clone() },
260 };
261 let is_link = matches!(kind, ReferenceKind::Link { .. });
262 let mut r = ResolvedReference::not_found();
263 r.kind = kind;
264 r.target_path = if is_link { None } else { Some(root_rel) };
265 r.size = size;
266 r.provenance = Some(provenance);
267 r.debug_check_invariant();
268 r
269 }
270 AssetResolution::Ambiguous { candidates, .. } => {
271 let mut r = ResolvedReference::not_found();
272 r.kind = ReferenceKind::Ambiguous;
273 r.candidates = candidates;
274 r
275 }
276 AssetResolution::NotFound => {
277 if matches!(ext_kind, ExtKind::Other) {
278 let mut r = ResolvedReference::not_found();
281 r.kind = ReferenceKind::Link { anchor };
282 r
283 } else {
284 ResolvedReference::not_found()
286 }
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 use crate::resolve::asset_class::FakeAssetIndex;
296 use crate::resolve::folder_class::FakeFolderIndex;
297 use crate::resolve::link_class::FakeUrlIndex;
298
299 fn ctx<'a>(
300 a: &'a FakeAssetIndex,
301 f: &'a FakeFolderIndex,
302 u: &'a FakeUrlIndex,
303 ) -> ReferenceContext<'a> {
304 ReferenceContext { assets: a, folders: f, urls: u }
305 }
306
307 #[test]
308 fn external_url_is_external() {
309 let a = FakeAssetIndex::new(&[]);
310 let f = FakeFolderIndex::new();
311 let u = FakeUrlIndex::new();
312 let r = classify_reference("https://example.com/x", "page.md", true, &ctx(&a, &f, &u));
313 assert_eq!(r.kind, ReferenceKind::External { url: "https://example.com/x".into() });
314 assert!(r.target_path.is_none());
315 }
316
317 #[test]
318 fn bare_anchor_is_anchor() {
319 let a = FakeAssetIndex::new(&[]);
320 let f = FakeFolderIndex::new();
321 let u = FakeUrlIndex::new();
322 let r = classify_reference("#section", "page.md", true, &ctx(&a, &f, &u));
323 assert_eq!(r.kind, ReferenceKind::Anchor);
324 }
325
326 #[test]
327 fn not_found_has_no_path() {
328 let r = ResolvedReference::not_found();
329 assert_eq!(r.kind, ReferenceKind::NotFound);
330 assert!(r.target_path.is_none());
331 r.debug_check_invariant();
332 }
333
334 #[test]
335 fn image_file_resolves_to_image_kind() {
336 let a = FakeAssetIndex::new(&["assets/photo.png"]);
337 let f = FakeFolderIndex::new();
338 let u = FakeUrlIndex::new();
339 let r = classify_reference("photo.png", "page.md", true, &ctx(&a, &f, &u));
340 assert_eq!(r.kind, ReferenceKind::Image);
341 assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
342 r.debug_check_invariant();
343 }
344
345 #[test]
346 fn html_file_resolves_to_iframe_with_size() {
347 let a = FakeAssetIndex::new(&["widgets/app.html"]);
348 let f = FakeFolderIndex::new();
349 let u = FakeUrlIndex::new();
350 let r = classify_reference("widgets/app.html|800x600", "page.md", true, &ctx(&a, &f, &u));
351 assert_eq!(r.kind, ReferenceKind::Iframe);
352 assert!(matches!(r.size, Some(crate::resolve::embed_renderer::Sizing::Box(_, _))));
353 }
354
355 #[test]
356 fn ambiguous_file_match_sets_candidates() {
357 let a = FakeAssetIndex::new(&["a/logo.png", "b/logo.png"]);
358 let f = FakeFolderIndex::new();
359 let u = FakeUrlIndex::new();
360 let r = classify_reference("logo.png", "page.md", true, &ctx(&a, &f, &u));
361 assert_eq!(r.kind, ReferenceKind::Ambiguous);
362 assert_eq!(r.candidates.len(), 2);
363 }
364
365 #[test]
366 fn folder_with_static_index_is_iframe() {
367 let a = FakeAssetIndex::new(&[]);
368 let mut f = FakeFolderIndex::new();
369 f.dirs.insert("Resources/app".into());
370 f.static_index.insert("Resources/app".into(), "index.html".into());
371 let u = FakeUrlIndex::new();
372 let r = classify_reference("/Resources/app/", "page.md", true, &ctx(&a, &f, &u));
373 assert_eq!(r.kind, ReferenceKind::FolderIndexIframe);
374 assert_eq!(r.target_path.as_deref(), Some("Resources/app"));
375 r.debug_check_invariant();
376 }
377
378 #[test]
379 fn folder_with_markdown_index_is_listing() {
380 let a = FakeAssetIndex::new(&[]);
381 let mut f = FakeFolderIndex::new();
382 f.dirs.insert("news".into());
383 f.md_index.insert("news".into());
384 let u = FakeUrlIndex::new();
385 let r = classify_reference("/news/", "page.md", true, &ctx(&a, &f, &u));
386 assert_eq!(r.kind, ReferenceKind::FolderListing);
387 r.debug_check_invariant();
388 }
389
390 #[test]
391 fn absolute_file_embed_resolves_to_image() {
392 let a = FakeAssetIndex::new(&["assets/photo.png"]);
396 let f = FakeFolderIndex::new(); let u = FakeUrlIndex::new();
398 let r = classify_reference("/assets/photo.png", "page.md", true, &ctx(&a, &f, &u));
399 assert_eq!(r.kind, ReferenceKind::Image);
400 assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
401 r.debug_check_invariant();
402 }
403
404 #[test]
405 fn trailing_slash_unresolved_folder_is_not_found() {
406 let a = FakeAssetIndex::new(&[]);
407 let f = FakeFolderIndex::new(); let u = FakeUrlIndex::new();
409 let r = classify_reference("/ghost/", "page.md", true, &ctx(&a, &f, &u));
410 assert_eq!(r.kind, ReferenceKind::NotFound);
411 }
412
413 #[test]
414 fn bare_note_name_is_link() {
415 let a = FakeAssetIndex::new(&[]);
416 let f = FakeFolderIndex::new();
417 let u = FakeUrlIndex::new();
418 let r = classify_reference("some-note", "page.md", true, &ctx(&a, &f, &u));
419 assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
420 assert!(r.target_path.is_none());
421 r.debug_check_invariant();
422 }
423
424 #[test]
425 fn missing_known_ext_asset_is_not_found() {
426 let a = FakeAssetIndex::new(&[]);
429 let f = FakeFolderIndex::new();
430 let u = FakeUrlIndex::new();
431 let r = classify_reference("missing.png", "page.md", true, &ctx(&a, &f, &u));
432 assert_eq!(r.kind, ReferenceKind::NotFound);
433 }
434
435 #[test]
436 fn non_embed_md_note_resolves_as_link_not_transclusion() {
437 let a = FakeAssetIndex::new(&["note.md"]);
438 let f = FakeFolderIndex::new();
439 let u = FakeUrlIndex::resolving(&[("note.md", "/note/")]);
440 let r = classify_reference("note.md", "page.md", false, &ctx(&a, &f, &u));
441 assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
442 assert_eq!(r.url.as_deref(), Some("/note/"));
443 }
444
445 #[test]
446 fn embed_md_is_still_transclusion() {
447 let a = FakeAssetIndex::new(&["note.md"]);
448 let f = FakeFolderIndex::new();
449 let u = FakeUrlIndex::new();
450 let r = classify_reference("note.md", "page.md", true, &ctx(&a, &f, &u));
451 assert_eq!(r.kind, ReferenceKind::Transclusion);
452 }
453
454 #[test]
455 fn non_embed_link_carries_anchor() {
456 let a = FakeAssetIndex::new(&[]);
457 let f = FakeFolderIndex::new();
458 let u = FakeUrlIndex::resolving(&[("note", "/note/")]);
459 let r = classify_reference("note#heading", "page.md", false, &ctx(&a, &f, &u));
460 assert_eq!(r.kind, ReferenceKind::Link { anchor: Some("heading".into()) });
461 assert_eq!(r.url.as_deref(), Some("/note/#heading"));
462 }
463
464 #[test]
465 fn build_safety_folder_marker_ignores_urls() {
466 let a = FakeAssetIndex::new(&[]);
467 let mut f = FakeFolderIndex::new();
468 f.dirs.insert("app".into());
469 f.static_index.insert("app".into(), "index.html".into());
470 let u = FakeUrlIndex::new();
471 let r = classify_reference("/app/", "page.md", true, &ctx(&a, &f, &u));
472 assert_eq!(r.kind, ReferenceKind::FolderIndexIframe);
473 }
474}