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
99fn filename_ext(path: &str) -> String {
103 let name = path.rsplit('/').next().unwrap_or(path);
104 match name.rsplit_once('.') {
105 Some((stem, ext)) if !stem.is_empty() => ext.to_lowercase(),
109 _ => String::new(),
110 }
111}
112
113pub fn classify_reference(
116 inner: &str,
117 from_source: &str,
118 is_embed: bool,
119 ctx: &ReferenceContext,
120) -> ResolvedReference {
121 let inner = inner.trim();
122
123 const EXTERNAL_PREFIXES: &[&str] =
125 &["http://", "https://", "//", "mailto:", "tel:", "data:"];
126 if EXTERNAL_PREFIXES.iter().any(|p| inner.starts_with(p)) {
127 let mut r = ResolvedReference::not_found();
128 r.kind = ReferenceKind::External { url: inner.to_string() };
129 r.debug_check_invariant();
130 return r;
131 }
132 if inner.starts_with('#') || inner.starts_with('?') {
134 let mut r = ResolvedReference::not_found();
135 r.kind = ReferenceKind::Anchor;
136 return r;
137 }
138
139 let (path_part, pothole) = match inner.split_once('|') {
141 Some((p, rest)) => (p.trim(), Some(rest)),
142 None => (inner, None),
143 };
144 let (path_no_anchor, anchor) = match path_part.split_once('#') {
145 Some((p, a)) => (p.trim(), Some(a.to_string())),
146 None => (path_part, None),
147 };
148 let size = pothole.and_then(crate::resolve::embed_renderer::Sizing::parse);
149
150 if !is_embed {
157 use crate::resolve::link_class::{classify_link, LinkClass};
158 return match classify_link(path_no_anchor, from_source, ctx.urls) {
159 LinkClass::Resolved { url } => {
160 let full = match &anchor {
161 Some(a) => format!("{}#{}", url, a),
162 None => url,
163 };
164 let mut r = ResolvedReference::not_found();
165 r.kind = ReferenceKind::Link { anchor: anchor.clone() };
166 r.url = Some(full);
167 r
168 }
169 LinkClass::Mismatch { canonical } => {
170 let full = match &anchor {
174 Some(a) => format!("{}#{}", canonical, a),
175 None => canonical.clone(),
176 };
177 let mut r = ResolvedReference::not_found();
178 r.kind = ReferenceKind::Link { anchor: anchor.clone() };
179 r.url = Some(full);
180 r.message = Some(format!("resolves to canonical URL {}", canonical));
181 r
182 }
183 LinkClass::External => {
184 let mut r = ResolvedReference::not_found();
185 r.kind = ReferenceKind::External { url: path_no_anchor.to_string() };
186 r
187 }
188 LinkClass::Anchor => {
189 let mut r = ResolvedReference::not_found();
190 r.kind = ReferenceKind::Anchor;
191 r
192 }
193 LinkClass::Broken => ResolvedReference::not_found(),
195 };
196 }
197
198 use crate::resolve::asset_class::{resolve_asset_ref, AssetResolution};
199 use crate::resolve::ext_kind::{reference_kind_for_ext, ExtKind};
200
201 let looks_like_folder = path_no_anchor.ends_with('/');
203 let folder_rel: Option<String> = if let Some(abs) = path_no_anchor.strip_prefix('/') {
204 Some(abs.trim_end_matches('/').to_string())
205 } else if looks_like_folder {
206 let from_dir = crate::resolve::parent_dir(from_source);
208 let mut parts: Vec<&str> = if from_dir.is_empty() {
209 vec![]
210 } else {
211 from_dir.split('/').collect()
212 };
213 for seg in path_no_anchor.trim_end_matches('/').split('/') {
214 match seg {
215 "" | "." => {}
216 ".." => {
217 parts.pop();
218 }
219 s => parts.push(s),
220 }
221 }
222 Some(parts.join("/"))
223 } else {
224 None
225 };
226 if let Some(folder_rel) = folder_rel {
227 let is_folder = looks_like_folder || ctx.folders.is_dir(&folder_rel);
233 if is_folder {
234 if ctx.folders.dir_has_markdown_index(&folder_rel) {
235 let mut r = ResolvedReference::not_found();
236 r.kind = ReferenceKind::FolderListing;
237 r.target_path = Some(folder_rel);
238 r.size = size;
239 r.debug_check_invariant();
240 return r;
241 }
242 if ctx.folders.dir_has_static_index(&folder_rel).is_some() {
243 let mut r = ResolvedReference::not_found();
244 r.kind = ReferenceKind::FolderIndexIframe;
245 r.target_path = Some(folder_rel);
246 r.size = size;
247 r.debug_check_invariant();
248 return r;
249 }
250 return ResolvedReference::not_found();
254 }
255 }
256
257 let query_ext_kind = reference_kind_for_ext(&filename_ext(path_no_anchor));
268
269 let resolved: Option<(String, AssetProvenance)> =
270 match resolve_asset_ref(path_no_anchor, from_source, ctx.assets) {
271 AssetResolution::Resolved { root_rel, provenance } => Some((root_rel, provenance)),
272 AssetResolution::Ambiguous { candidates, .. } => {
273 let mut r = ResolvedReference::not_found();
274 r.kind = ReferenceKind::Ambiguous;
275 r.candidates = candidates;
276 return r;
277 }
278 AssetResolution::NotFound
284 if is_embed && matches!(query_ext_kind, ExtKind::Other) =>
285 {
286 let mut hit = None;
287 for note_ext in ["md", "markdown"] {
288 match resolve_asset_ref(
289 &format!("{path_no_anchor}.{note_ext}"),
290 from_source,
291 ctx.assets,
292 ) {
293 AssetResolution::Resolved { root_rel, provenance } => {
294 hit = Some((root_rel, provenance));
295 break;
296 }
297 AssetResolution::Ambiguous { candidates, .. } => {
298 let mut r = ResolvedReference::not_found();
299 r.kind = ReferenceKind::Ambiguous;
300 r.candidates = candidates;
301 return r;
302 }
303 AssetResolution::NotFound => {}
304 }
305 }
306 hit
307 }
308 AssetResolution::NotFound => None,
309 };
310
311 match resolved {
312 Some((root_rel, provenance)) => {
313 let kind = match reference_kind_for_ext(&filename_ext(&root_rel)) {
315 ExtKind::Image => ReferenceKind::Image,
316 ExtKind::Iframe => ReferenceKind::Iframe,
317 ExtKind::Pdf => ReferenceKind::Pdf,
318 ExtKind::Video => ReferenceKind::Video,
319 ExtKind::Audio => ReferenceKind::Audio,
320 ExtKind::Model => ReferenceKind::Model,
321 ExtKind::Transclusion => ReferenceKind::Transclusion,
322 ExtKind::Notebook => ReferenceKind::Notebook,
323 ExtKind::Table => ReferenceKind::Table,
324 ExtKind::Other => ReferenceKind::Link { anchor: anchor.clone() },
326 };
327 let is_link = matches!(kind, ReferenceKind::Link { .. });
328 let mut r = ResolvedReference::not_found();
329 r.kind = kind;
330 r.target_path = if is_link { None } else { Some(root_rel) };
331 r.size = size;
332 r.provenance = Some(provenance);
333 r.debug_check_invariant();
334 r
335 }
336 None => {
337 if matches!(query_ext_kind, ExtKind::Other) {
338 let mut r = ResolvedReference::not_found();
341 r.kind = ReferenceKind::Link { anchor };
342 r
343 } else {
344 ResolvedReference::not_found()
346 }
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 use crate::resolve::asset_class::FakeAssetIndex;
356 use crate::resolve::folder_class::FakeFolderIndex;
357 use crate::resolve::link_class::FakeUrlIndex;
358
359 fn ctx<'a>(
360 a: &'a FakeAssetIndex,
361 f: &'a FakeFolderIndex,
362 u: &'a FakeUrlIndex,
363 ) -> ReferenceContext<'a> {
364 ReferenceContext { assets: a, folders: f, urls: u }
365 }
366
367 #[test]
368 fn external_url_is_external() {
369 let a = FakeAssetIndex::new(&[]);
370 let f = FakeFolderIndex::new();
371 let u = FakeUrlIndex::new();
372 let r = classify_reference("https://example.com/x", "page.md", true, &ctx(&a, &f, &u));
373 assert_eq!(r.kind, ReferenceKind::External { url: "https://example.com/x".into() });
374 assert!(r.target_path.is_none());
375 }
376
377 #[test]
378 fn bare_anchor_is_anchor() {
379 let a = FakeAssetIndex::new(&[]);
380 let f = FakeFolderIndex::new();
381 let u = FakeUrlIndex::new();
382 let r = classify_reference("#section", "page.md", true, &ctx(&a, &f, &u));
383 assert_eq!(r.kind, ReferenceKind::Anchor);
384 }
385
386 #[test]
387 fn not_found_has_no_path() {
388 let r = ResolvedReference::not_found();
389 assert_eq!(r.kind, ReferenceKind::NotFound);
390 assert!(r.target_path.is_none());
391 r.debug_check_invariant();
392 }
393
394 #[test]
395 fn image_file_resolves_to_image_kind() {
396 let a = FakeAssetIndex::new(&["assets/photo.png"]);
397 let f = FakeFolderIndex::new();
398 let u = FakeUrlIndex::new();
399 let r = classify_reference("photo.png", "page.md", true, &ctx(&a, &f, &u));
400 assert_eq!(r.kind, ReferenceKind::Image);
401 assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
402 r.debug_check_invariant();
403 }
404
405 #[test]
406 fn html_file_resolves_to_iframe_with_size() {
407 let a = FakeAssetIndex::new(&["widgets/app.html"]);
408 let f = FakeFolderIndex::new();
409 let u = FakeUrlIndex::new();
410 let r = classify_reference("widgets/app.html|800x600", "page.md", true, &ctx(&a, &f, &u));
411 assert_eq!(r.kind, ReferenceKind::Iframe);
412 assert!(matches!(r.size, Some(crate::resolve::embed_renderer::Sizing::Box(_, _))));
413 }
414
415 #[test]
416 fn ambiguous_file_match_sets_candidates() {
417 let a = FakeAssetIndex::new(&["a/logo.png", "b/logo.png"]);
418 let f = FakeFolderIndex::new();
419 let u = FakeUrlIndex::new();
420 let r = classify_reference("logo.png", "page.md", true, &ctx(&a, &f, &u));
421 assert_eq!(r.kind, ReferenceKind::Ambiguous);
422 assert_eq!(r.candidates.len(), 2);
423 }
424
425 #[test]
426 fn folder_with_static_index_is_iframe() {
427 let a = FakeAssetIndex::new(&[]);
428 let mut f = FakeFolderIndex::new();
429 f.dirs.insert("Resources/app".into());
430 f.static_index.insert("Resources/app".into(), "index.html".into());
431 let u = FakeUrlIndex::new();
432 let r = classify_reference("/Resources/app/", "page.md", true, &ctx(&a, &f, &u));
433 assert_eq!(r.kind, ReferenceKind::FolderIndexIframe);
434 assert_eq!(r.target_path.as_deref(), Some("Resources/app"));
435 r.debug_check_invariant();
436 }
437
438 #[test]
439 fn folder_with_markdown_index_is_listing() {
440 let a = FakeAssetIndex::new(&[]);
441 let mut f = FakeFolderIndex::new();
442 f.dirs.insert("news".into());
443 f.md_index.insert("news".into());
444 let u = FakeUrlIndex::new();
445 let r = classify_reference("/news/", "page.md", true, &ctx(&a, &f, &u));
446 assert_eq!(r.kind, ReferenceKind::FolderListing);
447 r.debug_check_invariant();
448 }
449
450 #[test]
451 fn absolute_file_embed_resolves_to_image() {
452 let a = FakeAssetIndex::new(&["assets/photo.png"]);
456 let f = FakeFolderIndex::new(); let u = FakeUrlIndex::new();
458 let r = classify_reference("/assets/photo.png", "page.md", true, &ctx(&a, &f, &u));
459 assert_eq!(r.kind, ReferenceKind::Image);
460 assert_eq!(r.target_path.as_deref(), Some("assets/photo.png"));
461 r.debug_check_invariant();
462 }
463
464 #[test]
465 fn trailing_slash_unresolved_folder_is_not_found() {
466 let a = FakeAssetIndex::new(&[]);
467 let f = FakeFolderIndex::new(); let u = FakeUrlIndex::new();
469 let r = classify_reference("/ghost/", "page.md", true, &ctx(&a, &f, &u));
470 assert_eq!(r.kind, ReferenceKind::NotFound);
471 }
472
473 #[test]
474 fn bare_note_name_is_link() {
475 let a = FakeAssetIndex::new(&[]);
476 let f = FakeFolderIndex::new();
477 let u = FakeUrlIndex::new();
478 let r = classify_reference("some-note", "page.md", true, &ctx(&a, &f, &u));
479 assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
480 assert!(r.target_path.is_none());
481 r.debug_check_invariant();
482 }
483
484 #[test]
485 fn missing_known_ext_asset_is_not_found() {
486 let a = FakeAssetIndex::new(&[]);
489 let f = FakeFolderIndex::new();
490 let u = FakeUrlIndex::new();
491 let r = classify_reference("missing.png", "page.md", true, &ctx(&a, &f, &u));
492 assert_eq!(r.kind, ReferenceKind::NotFound);
493 }
494
495 #[test]
496 fn non_embed_md_note_resolves_as_link_not_transclusion() {
497 let a = FakeAssetIndex::new(&["note.md"]);
498 let f = FakeFolderIndex::new();
499 let u = FakeUrlIndex::resolving(&[("note.md", "/note/")]);
500 let r = classify_reference("note.md", "page.md", false, &ctx(&a, &f, &u));
501 assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
502 assert_eq!(r.url.as_deref(), Some("/note/"));
503 }
504
505 #[test]
506 fn embed_md_is_still_transclusion() {
507 let a = FakeAssetIndex::new(&["note.md"]);
508 let f = FakeFolderIndex::new();
509 let u = FakeUrlIndex::new();
510 let r = classify_reference("note.md", "page.md", true, &ctx(&a, &f, &u));
511 assert_eq!(r.kind, ReferenceKind::Transclusion);
512 }
513
514 #[test]
522 fn bare_embed_resolves_markdown_note_as_transclusion() {
523 let a = FakeAssetIndex::new(&["support-band.md"]);
524 let f = FakeFolderIndex::new();
525 let u = FakeUrlIndex::new();
526 let r = classify_reference("support-band", "index.md", true, &ctx(&a, &f, &u));
527 assert_eq!(r.kind, ReferenceKind::Transclusion);
528 assert_eq!(r.target_path.as_deref(), Some("support-band.md"));
529 r.debug_check_invariant();
530 }
531
532 #[test]
533 fn bare_embed_prefers_source_relative_md_note() {
534 let a = FakeAssetIndex::new(&["support-band.md", "zh-hans/support-band.md"]);
538 let f = FakeFolderIndex::new();
539 let u = FakeUrlIndex::new();
540 let r = classify_reference("support-band", "zh-hans/index.md", true, &ctx(&a, &f, &u));
541 assert_eq!(r.kind, ReferenceKind::Transclusion);
542 assert_eq!(r.target_path.as_deref(), Some("zh-hans/support-band.md"));
543 r.debug_check_invariant();
544 }
545
546 #[test]
547 fn bare_embed_resolves_root_note_from_root_source() {
548 let a = FakeAssetIndex::new(&["support-band.md", "zh-hans/support-band.md"]);
551 let f = FakeFolderIndex::new();
552 let u = FakeUrlIndex::new();
553 let r = classify_reference("support-band", "index.md", true, &ctx(&a, &f, &u));
554 assert_eq!(r.kind, ReferenceKind::Transclusion);
555 assert_eq!(r.target_path.as_deref(), Some("support-band.md"));
556 r.debug_check_invariant();
557 }
558
559 #[test]
560 fn bare_embed_path_qualified_note_is_transclusion() {
561 let a = FakeAssetIndex::new(&["work/daowu.md"]);
563 let f = FakeFolderIndex::new();
564 let u = FakeUrlIndex::new();
565 let r = classify_reference("work/daowu", "index.md", true, &ctx(&a, &f, &u));
566 assert_eq!(r.kind, ReferenceKind::Transclusion);
567 assert_eq!(r.target_path.as_deref(), Some("work/daowu.md"));
568 }
569
570 #[test]
571 fn bare_embed_unresolved_stays_link() {
572 let a = FakeAssetIndex::new(&["other.md"]);
574 let f = FakeFolderIndex::new();
575 let u = FakeUrlIndex::new();
576 let r = classify_reference("support-band", "index.md", true, &ctx(&a, &f, &u));
577 assert_eq!(r.kind, ReferenceKind::Link { anchor: None });
578 assert!(r.target_path.is_none());
579 }
580
581 #[test]
582 fn bare_link_not_embed_does_not_use_md_fallback() {
583 let a = FakeAssetIndex::new(&["support-band.md"]);
586 let f = FakeFolderIndex::new();
587 let u = FakeUrlIndex::new();
588 let r = classify_reference("support-band", "index.md", false, &ctx(&a, &f, &u));
589 assert_ne!(r.kind, ReferenceKind::Transclusion);
590 }
591
592 #[test]
593 fn non_embed_link_carries_anchor() {
594 let a = FakeAssetIndex::new(&[]);
595 let f = FakeFolderIndex::new();
596 let u = FakeUrlIndex::resolving(&[("note", "/note/")]);
597 let r = classify_reference("note#heading", "page.md", false, &ctx(&a, &f, &u));
598 assert_eq!(r.kind, ReferenceKind::Link { anchor: Some("heading".into()) });
599 assert_eq!(r.url.as_deref(), Some("/note/#heading"));
600 }
601}