1#![doc(
10 html_logo_url = "https://mosaiclang.dev/assets/A4.svg",
11 html_favicon_url = "https://mosaiclang.dev/assets/A4.svg"
12)]
13mod bibliography;
14mod dependency;
15#[doc(hidden)]
16pub mod image;
17mod image_lower;
18#[doc(hidden)]
19pub mod inline;
20#[doc(hidden)]
21pub mod list;
22#[doc(hidden)]
23pub mod pageref;
24#[doc(hidden)]
25pub mod resolve;
26mod semantic_hash;
27#[doc(hidden)]
28pub mod set;
29#[doc(hidden)]
30pub mod set_schema;
31mod suggest;
32
33use std::collections::{BTreeMap, BTreeSet};
34
35use mos_core::{
36 AttrMap, AttrValue, CollectingSink, Diagnostic, Document, NodeId, NodeKind, NodeSpec, Severity,
37 SourceSpan,
38};
39use mos_parse::{DirectiveKind, Item, RawBlockKind, RawBlockView, SetArg, SetValue, SyntaxTree};
40
41pub use dependency::{
42 ExternalDependency, FileIdentity, Fingerprint, RACY_WINDOW, fingerprint_bytes, fingerprint_file,
43};
44pub use mos_bib::Bibliography;
45pub use pageref::{PageFixpointOutcome, resolve_page_reference_fixpoint, resolve_page_references};
46pub use resolve::resolve;
47
48use bibliography::{load_bibliography, lower_bibliography_directive, resolve_citations};
49use dependency::{DependencySet, ExternalInputs};
50use image_lower::{lower_figure_directive, lower_image_directive};
51use inline::lower_inlines;
52use list::lower as lower_list;
53use set::lower_set_directive;
54
55const LABEL_SPAN_START_ATTR: &str = "label_span.start";
56const LABEL_SPAN_END_ATTR: &str = "label_span.end";
57pub const DOC_ATTR: &str = "doc";
60pub const CODE_LANGUAGE_ATTR: &str = "raw.lang";
62
63fn insert_label_attributes(attributes: &mut AttrMap, label: &str, label_span: Option<&SourceSpan>) {
64 attributes.insert("label".to_owned(), AttrValue::Str(label.to_owned()));
65 let Some(span) = label_span else {
66 return;
67 };
68 let (Ok(start), Ok(end)) = (i64::try_from(span.start()), i64::try_from(span.end())) else {
69 return;
73 };
74 attributes.insert(LABEL_SPAN_START_ATTR.to_owned(), AttrValue::Int(start));
75 attributes.insert(LABEL_SPAN_END_ATTR.to_owned(), AttrValue::Int(end));
76}
77
78pub(crate) fn string_content_span(value_span: &SourceSpan) -> SourceSpan {
80 if value_span.end() > value_span.start().saturating_add(1) {
81 SourceSpan::new(
82 value_span.file.clone(),
83 value_span.start() + 1,
84 value_span.end() - 1,
85 )
86 } else {
87 value_span.clone()
88 }
89}
90
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct DocumentMetadata {
112 pub title: Option<String>,
113 pub author: Option<String>,
114 pub language: Option<String>,
115}
116
117#[derive(Debug)]
140pub struct LowerResult {
141 pub document: Document,
144 pub diagnostics: Vec<Diagnostic>,
145 pub metadata: DocumentMetadata,
146 pub external_dependencies: Vec<ExternalDependency>,
154 pub bibliography: Bibliography,
158 pub bibliography_complete: bool,
161 pub citation_spans: Vec<std::ops::Range<usize>>,
164}
165
166impl LowerResult {
167 #[must_use]
179 pub fn reads_external_resources(&self) -> bool {
180 !self.external_dependencies.is_empty()
181 }
182
183 #[must_use]
195 pub fn has_errors(&self) -> bool {
196 self.diagnostics
197 .iter()
198 .any(|d| d.severity() == Severity::Error)
199 }
200}
201
202#[derive(Copy, Clone, Default, Debug)]
214pub struct Evaluator;
215
216impl Evaluator {
217 #[must_use]
229 pub const fn new() -> Self {
230 Self
231 }
232
233 #[must_use]
258 pub fn evaluate(tree: &SyntaxTree) -> LowerResult {
259 let mut lowered = Self::evaluate_unhashed(tree);
260 semantic_hash::stamp(&mut lowered.document, &lowered.external_dependencies);
261 lowered
262 }
263
264 fn evaluate_unhashed(tree: &SyntaxTree) -> LowerResult {
265 let mut state = EvaluationState::new(tree);
266 for item in &tree.items {
267 state.lower_item(item, &tree.file);
268 }
269 state.finish(tree)
270 }
271}
272
273struct EvaluationState {
274 document: Document,
275 diagnostics: Vec<Diagnostic>,
276 metadata: DocumentMetadata,
277 current_text_size_pt: f64,
278 dependencies: DependencySet,
279 pending_doc: Option<String>,
284}
285
286impl EvaluationState {
287 fn new(tree: &SyntaxTree) -> Self {
288 Self {
289 document: Document::new(tree.file.clone()),
290 diagnostics: Vec::new(),
291 metadata: DocumentMetadata::default(),
292 current_text_size_pt: 11.0,
293 dependencies: DependencySet::default(),
294 pending_doc: None,
295 }
296 }
297
298 fn finish(self, tree: &SyntaxTree) -> LowerResult {
299 LowerResult {
300 document: self.document,
301 diagnostics: self.diagnostics,
302 metadata: self.metadata,
303 external_dependencies: self.dependencies.into_vec(),
304 bibliography: Bibliography::default(),
305 bibliography_complete: false,
306 citation_spans: tree.citation_spans.clone(),
307 }
308 }
309
310 fn lower_item(&mut self, item: &Item, source_file: &std::path::Path) {
311 match item {
315 Item::DocComment { text, .. } => {
316 self.pending_doc = Some(text.clone());
317 }
318 Item::Heading {
319 level,
320 inlines,
321 label,
322 label_span,
323 span,
324 } => {
325 let doc = self.pending_doc.take();
326 self.lower_heading(
327 *level,
328 inlines,
329 label.as_deref(),
330 label_span.as_ref(),
331 span,
332 doc,
333 );
334 }
335 Item::Paragraph {
336 inlines,
337 label,
338 label_span,
339 span,
340 } => {
341 let doc = self.pending_doc.take();
342 self.lower_paragraph(inlines, label.as_deref(), label_span.as_ref(), span, doc);
343 }
344 Item::List {
345 ordered,
346 items,
347 span,
348 } => {
349 self.pending_doc = None;
350 let root = self.document.root;
351 lower_list(&mut self.document, root, *ordered, items, span);
352 }
353 Item::RawBlock { .. } => {
354 self.pending_doc = None;
355 let root = self.document.root;
356 if let Some(raw) = item.as_raw_block() {
357 lower_raw_block(&mut self.document, root, raw);
358 }
359 }
360 Item::Set {
361 kind,
362 name,
363 args,
364 span,
365 } => {
366 self.pending_doc = None;
367 self.lower_directive(*kind, name, args, span, source_file);
368 }
369 }
370 }
371
372 fn lower_heading(
373 &mut self,
374 level: u8,
375 inlines: &[mos_parse::Inline],
376 label: Option<&str>,
377 label_span: Option<&SourceSpan>,
378 span: &SourceSpan,
379 doc: Option<String>,
380 ) {
381 let mut attributes: AttrMap = BTreeMap::new();
382 attributes.insert("level".to_owned(), AttrValue::Int(i64::from(level)));
383 if let Some(id) = label {
384 insert_label_attributes(&mut attributes, id, label_span);
385 }
386 if let Some(doc) = doc {
387 attributes.insert(DOC_ATTR.to_owned(), AttrValue::Str(doc));
388 }
389 let heading = self.document.alloc_child(
390 self.document.root,
391 NodeSpec::new(NodeKind::Section, span.clone()).with_attributes(attributes),
392 );
393 lower_inlines(&mut self.document, heading, inlines);
394 }
395
396 fn lower_paragraph(
397 &mut self,
398 inlines: &[mos_parse::Inline],
399 label: Option<&str>,
400 label_span: Option<&SourceSpan>,
401 span: &SourceSpan,
402 doc: Option<String>,
403 ) {
404 let mut attributes: AttrMap = BTreeMap::new();
405 if let Some(id) = label {
406 insert_label_attributes(&mut attributes, id, label_span);
407 }
408 if let Some(doc) = doc {
409 attributes.insert(DOC_ATTR.to_owned(), AttrValue::Str(doc));
410 }
411 let para = self.document.alloc_child(
412 self.document.root,
413 NodeSpec::new(NodeKind::Paragraph, span.clone()).with_attributes(attributes),
414 );
415 lower_inlines(&mut self.document, para, inlines);
416 }
417
418 fn lower_directive(
419 &mut self,
420 kind: DirectiveKind,
421 name: &str,
422 args: &[SetArg],
423 span: &SourceSpan,
424 source_file: &std::path::Path,
425 ) {
426 let mut inputs = ExternalInputs {
427 source_file,
428 dependencies: &mut self.dependencies,
429 };
430 match kind {
431 DirectiveKind::Image => {
432 let root = self.document.root;
433 lower_image_directive(
434 &mut self.document,
435 root,
436 args,
437 span,
438 &mut inputs,
439 self.current_text_size_pt,
440 &mut self.diagnostics,
441 );
442 }
443 DirectiveKind::Figure => {
444 let root = self.document.root;
445 lower_figure_directive(
446 &mut self.document,
447 root,
448 args,
449 span,
450 &mut inputs,
451 self.current_text_size_pt,
452 &mut self.diagnostics,
453 );
454 }
455 DirectiveKind::Bibliography => {
456 let root = self.document.root;
457 lower_bibliography_directive(
458 &mut self.document,
459 root,
460 args,
461 span,
462 &mut inputs,
463 &mut self.diagnostics,
464 );
465 }
466 DirectiveKind::Set => {
467 let root = self.document.root;
468 lower_set_directive(
469 &mut self.document,
470 root,
471 name,
472 args,
473 span,
474 &mut self.metadata,
475 &mut self.current_text_size_pt,
476 &mut self.diagnostics,
477 );
478 }
479 }
480 }
481}
482
483fn lower_raw_block(document: &mut Document, root: NodeId, raw: RawBlockView<'_>) {
484 let mut attributes: AttrMap = BTreeMap::new();
485 attributes.insert("text".to_owned(), AttrValue::Str(raw.text.to_owned()));
486 if let Some(id) = raw.label {
487 insert_label_attributes(&mut attributes, id, raw.label_span);
488 }
489 if raw.kind == RawBlockKind::Code
493 && let Some(SetArg::Named {
494 value: SetValue::Str(language) | SetValue::Ident(language),
495 ..
496 }) = raw
497 .args
498 .iter()
499 .rev()
500 .find(|arg| matches!(arg, SetArg::Named { key, .. } if key == "lang"))
501 {
502 attributes.insert(
503 CODE_LANGUAGE_ATTR.to_owned(),
504 AttrValue::Str(language.clone()),
505 );
506 }
507 attributes.insert(
508 "raw.kind".to_owned(),
509 AttrValue::Str(
510 match raw.kind {
511 RawBlockKind::Pre => "pre",
512 RawBlockKind::Code => "code",
513 }
514 .to_owned(),
515 ),
516 );
517 document.alloc_child(
518 root,
519 NodeSpec::new(NodeKind::Raw, raw.span.clone()).with_attributes(attributes),
520 );
521}
522
523#[must_use]
537pub fn lower(src: &str, file: &std::path::Path) -> LowerResult {
538 let mut sink = CollectingSink::new();
539 let tree = match mos_parse::parse(src, file, &mut sink) {
540 Ok(tree) => tree,
541 Err(mos_core::DiagnosticAbort) => {
544 return LowerResult {
545 document: Document::new(file.to_path_buf()),
546 diagnostics: sink.into_diagnostics(),
547 metadata: DocumentMetadata::default(),
548 external_dependencies: Vec::new(),
549 bibliography: Bibliography::default(),
550 bibliography_complete: false,
551 citation_spans: Vec::new(),
552 };
553 }
554 };
555 let mut diagnostics = sink.into_diagnostics();
556 let mut lowered = lower_tree(&tree);
557 diagnostics.append(&mut lowered.diagnostics);
558 LowerResult {
559 document: lowered.document,
560 diagnostics,
561 metadata: lowered.metadata,
562 external_dependencies: lowered.external_dependencies,
563 bibliography: lowered.bibliography,
564 bibliography_complete: lowered.bibliography_complete,
565 citation_spans: lowered.citation_spans,
566 }
567}
568
569#[must_use]
593pub fn lower_tree(tree: &SyntaxTree) -> LowerResult {
594 let mut lowered = Evaluator::evaluate_unhashed(tree);
595 let mut diagnostics = std::mem::take(&mut lowered.diagnostics);
596 let mut dependencies = DependencySet::from(std::mem::take(&mut lowered.external_dependencies));
597 let loaded_bibliography =
598 load_bibliography(&lowered.document, &mut diagnostics, &mut dependencies);
599 let external_dependencies = dependencies.into_vec();
600 semantic_hash::stamp(&mut lowered.document, &external_dependencies);
603 let (bibliography, bibliography_complete) =
604 resolve_citations(&mut lowered.document, &mut diagnostics, loaded_bibliography);
605 let bib_keys: BTreeSet<String> = bibliography.entries.keys().cloned().collect();
606 diagnostics.extend(resolve(&mut lowered.document, &bib_keys));
607 LowerResult {
608 document: lowered.document,
609 diagnostics,
610 metadata: lowered.metadata,
611 external_dependencies,
612 bibliography,
613 bibliography_complete,
614 citation_spans: lowered.citation_spans,
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 #![allow(
621 clippy::unwrap_used,
622 clippy::expect_used,
623 clippy::panic,
624 reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
625 )]
626 use std::path::PathBuf;
627
628 use mos_core::{NodeKind, codes};
629
630 use super::*;
631
632 #[test]
633 fn code_language_is_authored_metadata_with_last_argument_winning() {
634 let src = "#code(lang: \"rust\", lang: python)[[print(1)]] <sample>\n";
635 let result = lower(src, &PathBuf::from("main.mos"));
636 assert!(!result.has_errors());
637 let node = result
638 .document
639 .nodes()
640 .find(|node| node.kind == NodeKind::Raw)
641 .unwrap();
642 assert_eq!(
643 node.attributes[CODE_LANGUAGE_ATTR],
644 AttrValue::Str("python".into())
645 );
646 assert_eq!(node.attributes["text"], AttrValue::Str("print(1)".into()));
647 assert_eq!(node.attributes["label"], AttrValue::Str("sample".into()));
648 let changed = lower(
649 &src.replace("lang: python", "lang: rust"),
650 &PathBuf::from("main.mos"),
651 );
652 let changed = changed
653 .document
654 .nodes()
655 .find(|node| node.kind == NodeKind::Raw)
656 .unwrap();
657 assert_ne!(changed.content_hash(), node.content_hash());
658 }
659
660 #[test]
661 fn absent_or_nontext_code_language_and_pre_options_stay_uninterpreted() {
662 for src in [
663 "#code[[body]]",
664 "#code(lang: \"rust\", lang: 42)[[body]]",
665 "#pre(lang: \"rust\")[[body]]",
666 ] {
667 let result = lower(src, &PathBuf::from("main.mos"));
668 assert!(!result.has_errors(), "{:?}", result.diagnostics);
669 let node = result
670 .document
671 .nodes()
672 .find(|node| node.kind == NodeKind::Raw)
673 .unwrap();
674 assert!(!node.attributes.contains_key(CODE_LANGUAGE_ATTR));
675 assert_eq!(node.attributes["text"], AttrValue::Str("body".into()));
676 }
677 }
678
679 #[cfg(target_pointer_width = "64")]
680 #[test]
681 fn label_attributes_omit_unrepresentable_span_bounds() {
682 let too_large = usize::try_from(i64::MAX).unwrap().saturating_add(1);
683 let span = SourceSpan::new(
684 PathBuf::from("test.mos"),
685 too_large,
686 too_large.saturating_add(1),
687 );
688 let mut attributes = AttrMap::new();
689
690 insert_label_attributes(&mut attributes, "huge", Some(&span));
691
692 assert_eq!(
693 attributes.get("label"),
694 Some(&AttrValue::Str("huge".to_owned()))
695 );
696 assert!(!attributes.contains_key(LABEL_SPAN_START_ATTR));
697 assert!(!attributes.contains_key(LABEL_SPAN_END_ATTR));
698 }
699
700 #[test]
701 fn doc_comment_attaches_to_following_heading() {
702 let file = PathBuf::from("test.mos");
703 let r = lower("/** Section doc. */\n= Intro <intro>\n", &file);
704 let section = r
705 .document
706 .nodes()
707 .find(|n| n.kind == NodeKind::Section)
708 .expect("a section node");
709 assert_eq!(
710 section.attributes.get(DOC_ATTR),
711 Some(&AttrValue::Str("Section doc.".to_owned())),
712 );
713 }
714
715 #[test]
716 fn doc_comment_attaches_to_following_paragraph() {
717 let file = PathBuf::from("test.mos");
718 let r = lower("/** Para doc. */\nBody text.\n", &file);
719 let para = r
720 .document
721 .nodes()
722 .find(|n| n.kind == NodeKind::Paragraph)
723 .expect("a paragraph node");
724 assert_eq!(
725 para.attributes.get(DOC_ATTR),
726 Some(&AttrValue::Str("Para doc.".to_owned())),
727 );
728 }
729
730 #[test]
731 fn doc_comment_does_not_leak_past_a_non_documentable_block() {
732 let file = PathBuf::from("test.mos");
735 let r = lower("/** stray */\n#set document(title: \"x\")\n\n= H\n", &file);
736 let section = r
737 .document
738 .nodes()
739 .find(|n| n.kind == NodeKind::Section)
740 .expect("a section node");
741 assert!(
742 !section.attributes.contains_key(DOC_ATTR),
743 "doc must not leak past #set"
744 );
745 }
746
747 #[test]
748 fn doc_comment_does_not_attach_to_or_leak_past_a_list() {
749 let file = PathBuf::from("test.mos");
750 let r = lower("/** stray */\n- item\n\n= H\n", &file);
751 let list = r
752 .document
753 .nodes()
754 .find(|n| n.kind == NodeKind::List)
755 .expect("a list node");
756 assert!(
757 !list.attributes.contains_key(DOC_ATTR),
758 "doc must not attach to a list"
759 );
760 let section = r
761 .document
762 .nodes()
763 .find(|n| n.kind == NodeKind::Section)
764 .expect("a section node");
765 assert!(
766 !section.attributes.contains_key(DOC_ATTR),
767 "doc must not leak past a list"
768 );
769 }
770
771 #[test]
772 fn doc_comment_does_not_attach_to_or_leak_past_a_raw_block() {
773 let file = PathBuf::from("test.mos");
774 let r = lower("/** stray */\n#code[[x]]\n\n= H\n", &file);
775 let raw = r
776 .document
777 .nodes()
778 .find(|n| n.kind == NodeKind::Raw)
779 .expect("a raw node");
780 assert!(
781 !raw.attributes.contains_key(DOC_ATTR),
782 "doc must not attach to a raw block"
783 );
784 let section = r
785 .document
786 .nodes()
787 .find(|n| n.kind == NodeKind::Section)
788 .expect("a section node");
789 assert!(
790 !section.attributes.contains_key(DOC_ATTR),
791 "doc must not leak past a raw block"
792 );
793 }
794
795 #[test]
796 fn plain_block_comment_attaches_no_doc() {
797 let file = PathBuf::from("test.mos");
798 let r = lower("/* not doc */\n= H\n", &file);
799 let section = r
800 .document
801 .nodes()
802 .find(|n| n.kind == NodeKind::Section)
803 .expect("a section node");
804 assert!(!section.attributes.contains_key(DOC_ATTR));
805 }
806
807 #[test]
808 fn lowering_records_external_dependencies_with_fingerprints() {
809 let dir = unique_temp_dir("deps");
810 let source = dir.join("main.mos");
811 let image = dir.join("x.png");
812 let bib = dir.join("refs.bib");
813 std::fs::write(&image, b"not a png").unwrap();
814 std::fs::write(&bib, "@book{k, title={T}}\n").unwrap();
815 let r = lower(
816 "#image(\"x.png\")\n#image(\"x.png\")\n#bibliography(\"refs.bib\")\n#image(\"missing.png\")\n",
817 &source,
818 );
819
820 let deps = &r.external_dependencies;
821 assert_eq!(
822 deps.iter().map(|d| d.path.clone()).collect::<Vec<_>>(),
823 vec![dir.join("missing.png"), bib, image.clone()],
824 "one entry per distinct file, sorted by path: {deps:?}"
825 );
826 let content = |dep: &ExternalDependency| dep.fingerprint.map(|f| f.content);
827 assert_eq!(content(&deps[0]), None, "a missing file records no hash");
828 assert_eq!(
829 content(&deps[1]),
830 Some(fingerprint_bytes(b"@book{k, title={T}}\n"))
831 );
832 assert_eq!(content(&deps[2]), Some(fingerprint_bytes(b"not a png")));
833 assert!(deps.iter().all(ExternalDependency::is_current));
834
835 std::fs::write(&image, b"changed").unwrap();
836 assert!(
837 !deps[2].is_current(),
838 "a rewritten file is no longer current"
839 );
840 assert!(deps[1].is_current());
841 std::fs::remove_dir_all(&dir).ok();
842 }
843
844 #[cfg(unix)]
845 #[test]
846 fn non_utf8_bibliography_path_warns_and_records_the_real_file() {
847 use std::ffi::OsStr;
848 use std::os::unix::ffi::OsStrExt;
849
850 let dir = unique_temp_dir("non-utf8").join(OsStr::from_bytes(b"caf\xe9"));
851 std::fs::create_dir_all(&dir).unwrap();
852 let bib = dir.join("refs.bib");
853 std::fs::write(&bib, "@book{k, title={T}}\n").unwrap();
854 let source = dir.join("main.mos");
855 let r = lower("#bibliography(\"refs.bib\")\n\nsee [@k]\n", &source);
856
857 let warning = r
858 .diagnostics
859 .iter()
860 .find(|d| d.def().code() == codes::MOS0041.code())
861 .expect("MOS0041 for the unloadable source");
862 assert!(
863 warning.message().contains("non-UTF-8"),
864 "message: {}",
865 warning.message()
866 );
867 let node = r
868 .document
869 .nodes()
870 .find(|n| n.kind == NodeKind::Bibliography)
871 .expect("the source is still declared");
872 assert_eq!(
873 node.attributes.get("src"),
874 Some(&AttrValue::Str("refs.bib".to_owned()))
875 );
876 assert!(
877 !node.attributes.contains_key("resolved_path"),
878 "a path the model cannot name is left unresolved"
879 );
880 assert!(
881 !r.diagnostics
882 .iter()
883 .any(|d| d.def().code() == codes::MOS0045.code()),
884 "an unloadable source makes the record set incomplete, so no key is reported missing"
885 );
886 assert_eq!(r.external_dependencies.len(), 1);
887 assert_eq!(r.external_dependencies[0].path, bib);
888 assert!(r.external_dependencies[0].fingerprint.is_some());
889 assert!(r.external_dependencies[0].is_current());
890 std::fs::remove_dir_all(dir.parent().unwrap()).ok();
891 }
892
893 #[test]
894 fn reads_external_resources_flags_filesystem_directives() {
895 let file = PathBuf::from("test.mos");
896 assert!(
898 !lower("= Title <t>\n\nSee @t\n", &file).reads_external_resources(),
899 "a source with no filesystem directives lowers purely"
900 );
901 assert!(
905 lower("#image(\"missing.png\")\n", &file).reads_external_resources(),
906 "`#image` reads an external file"
907 );
908 assert!(
909 lower("#figure(image: \"missing.png\")\n", &file).reads_external_resources(),
910 "`#figure` loads an external image"
911 );
912 assert!(
913 lower("#bibliography(\"missing.bib\")\n", &file).reads_external_resources(),
914 "`#bibliography` reads an external source file"
915 );
916 }
917
918 #[test]
919 fn lowerer_stamps_paired_label_span_covering_the_label_token() {
920 let src = "= Intro <intro>\n";
928 let r = lower(src, &PathBuf::from("test.mos"));
929 assert!(!r.has_errors(), "{:?}", r.diagnostics);
930
931 let section = r
932 .document
933 .nodes()
934 .find(|node| {
935 node.kind == NodeKind::Section
936 && node.attributes.get("label") == Some(&AttrValue::Str("intro".to_owned()))
937 })
938 .expect("a Section node carrying label `intro`");
939
940 let (Some(&AttrValue::Int(start)), Some(&AttrValue::Int(end))) = (
941 section.attributes.get(LABEL_SPAN_START_ATTR),
942 section.attributes.get(LABEL_SPAN_END_ATTR),
943 ) else {
944 panic!(
945 "expected paired Int `label_span.*` attrs, got {:?} / {:?}",
946 section.attributes.get(LABEL_SPAN_START_ATTR),
947 section.attributes.get(LABEL_SPAN_END_ATTR),
948 );
949 };
950
951 let start = usize::try_from(start).expect("label_span.start fits usize");
952 let end = usize::try_from(end).expect("label_span.end fits usize");
953 assert!(
954 start <= end,
955 "label span start {start} must not exceed end {end}"
956 );
957 assert_eq!(
958 src.get(start..end),
959 Some("intro"),
960 "label span must cover exactly the `intro` token, not the whole heading"
961 );
962 }
963
964 #[test]
965 fn reference_nodes_carry_stamped_label_span() {
966 let src = "= Intro <intro>\n\nsee @intro and @page(intro)\n";
971 let r = lower(src, &PathBuf::from("test.mos"));
972 assert!(!r.has_errors(), "{:?}", r.diagnostics);
973
974 for kind in [NodeKind::Reference, NodeKind::PageReference] {
975 let node = r
976 .document
977 .nodes()
978 .find(|node| node.kind == kind)
979 .unwrap_or_else(|| panic!("expected a {kind:?} node"));
980 let (Some(&AttrValue::Int(start)), Some(&AttrValue::Int(end))) = (
981 node.attributes.get(LABEL_SPAN_START_ATTR),
982 node.attributes.get(LABEL_SPAN_END_ATTR),
983 ) else {
984 panic!(
985 "{kind:?} node missing paired `label_span.*`: {:?}",
986 node.attributes
987 );
988 };
989 let start = usize::try_from(start).expect("label_span.start fits usize");
990 let end = usize::try_from(end).expect("label_span.end fits usize");
991 assert_eq!(
992 src.get(start..end),
993 Some("intro"),
994 "{kind:?} label span must cover exactly the `intro` identifier"
995 );
996 }
997 }
998
999 #[test]
1000 fn lowers_heading_and_paragraph() {
1001 let r = lower(
1002 "= Hello\n\nbody *italic* text\n",
1003 &PathBuf::from("test.mos"),
1004 );
1005 assert!(!r.has_errors());
1006 assert_eq!(r.document.len(), 1 + 2 + 1 + 3);
1009
1010 let kinds: Vec<NodeKind> = r.document.nodes().map(|n| n.kind).collect();
1011 assert_eq!(kinds[0], NodeKind::Document);
1012 assert!(kinds.contains(&NodeKind::Section));
1013 assert!(kinds.contains(&NodeKind::Paragraph));
1014 assert!(kinds.contains(&NodeKind::Emphasis));
1015 }
1016
1017 #[test]
1018 fn lowers_nested_bold_italic_inline() {
1019 let r = lower("***both***\n", &PathBuf::from("test.mos"));
1020 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1021 assert!(
1022 r.document.nodes().any(|n| n.kind == NodeKind::BoldItalic),
1023 "expected bold-italic node in {:?}",
1024 r.document.nodes().map(|n| n.kind).collect::<Vec<_>>()
1025 );
1026 }
1027
1028 #[test]
1029 fn root_owns_top_level_items() {
1030 let r = lower("= A\n\n= B\n\npara\n", &PathBuf::from("test.mos"));
1031 let root = r.document.get(r.document.root).unwrap();
1032 assert_eq!(root.children.len(), 3);
1033 }
1034
1035 #[test]
1036 fn hard_break_lowers_to_hardbreak_node_without_text_attr() {
1037 let r = lower("foo\\\\bar\n", &PathBuf::from("test.mos"));
1038 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1039
1040 let root = r.document.get(r.document.root).unwrap();
1043 let paragraph_id = *root.children.first().unwrap();
1044 let paragraph = r.document.get(paragraph_id).unwrap();
1045 let inline_kinds: Vec<NodeKind> = paragraph
1046 .children
1047 .iter()
1048 .filter_map(|id| r.document.get(*id).map(|n| n.kind))
1049 .collect();
1050 assert_eq!(
1051 inline_kinds,
1052 vec![NodeKind::Text, NodeKind::HardBreak, NodeKind::Text],
1053 "got {inline_kinds:?}"
1054 );
1055
1056 let hardbreak_id = paragraph.children[1];
1059 let hardbreak = r.document.get(hardbreak_id).unwrap();
1060 assert!(
1061 hardbreak.attributes.is_empty(),
1062 "expected empty attribute map on HardBreak, got {:?}",
1063 hardbreak.attributes
1064 );
1065 }
1066
1067 fn write_tiny_png(name: &str) -> PathBuf {
1073 let dir = std::env::temp_dir().join(format!(
1074 "mos-eval-image-{}-{}",
1075 name,
1076 std::time::SystemTime::now()
1077 .duration_since(std::time::UNIX_EPOCH)
1078 .map_or(0, |d| d.as_nanos())
1079 ));
1080 std::fs::create_dir_all(&dir).unwrap();
1081 let path = dir.join(name);
1082 let mut buf = ::image::RgbaImage::new(3, 2);
1083 for x in 0_u32..3 {
1084 for y in 0_u32..2 {
1085 let r = u8::try_from(x * 80).unwrap_or(0);
1086 let g = u8::try_from(y * 120).unwrap_or(0);
1087 buf.put_pixel(x, y, ::image::Rgba([r, g, 200, 255]));
1088 }
1089 }
1090 buf.save(&path).unwrap();
1091 path
1092 }
1093
1094 #[test]
1095 fn image_directive_attaches_decoded_pixels() {
1096 let png_path = write_tiny_png("tiny.png");
1097 let source = png_path.parent().unwrap().join("main.mos");
1098 std::fs::write(&source, "#image(\"tiny.png\")\n").unwrap();
1099 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1100 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1101 let image_node = r
1102 .document
1103 .nodes()
1104 .find(|n| n.kind == NodeKind::Image)
1105 .expect("Image node");
1106 assert_eq!(
1107 image_node.attributes.get("src"),
1108 Some(&AttrValue::Str("tiny.png".to_owned()))
1109 );
1110 assert_eq!(
1111 image_node.attributes.get("pixel_width"),
1112 Some(&AttrValue::Int(3))
1113 );
1114 assert_eq!(
1115 image_node.attributes.get("pixel_height"),
1116 Some(&AttrValue::Int(2))
1117 );
1118 match image_node.attributes.get("pixels") {
1119 Some(AttrValue::Bytes(b)) => assert_eq!(b.len(), 3 * 3 * 2),
1120 other => panic!("expected pixel bytes, got {other:?}"),
1121 }
1122 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1123 }
1124
1125 #[test]
1126 fn image_directive_records_explicit_dimensions() {
1127 let png_path = write_tiny_png("dims.png");
1128 let source = png_path.parent().unwrap().join("main.mos");
1129 std::fs::write(
1130 &source,
1131 "#image(\"dims.png\", width: 100pt, height: 60pt, alt: \"a tiny image\")\n",
1132 )
1133 .unwrap();
1134 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1135 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1136 let image_node = r
1137 .document
1138 .nodes()
1139 .find(|n| n.kind == NodeKind::Image)
1140 .expect("Image node");
1141 assert_eq!(
1142 image_node.attributes.get("width"),
1143 Some(&AttrValue::Length(100.0))
1144 );
1145 assert_eq!(
1146 image_node.attributes.get("height"),
1147 Some(&AttrValue::Length(60.0))
1148 );
1149 assert_eq!(
1150 image_node.attributes.get("alt"),
1151 Some(&AttrValue::Str("a tiny image".to_owned()))
1152 );
1153 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1154 }
1155
1156 #[test]
1157 fn image_em_width_resolves_against_current_text_size() {
1158 let png_path = write_tiny_png("em.png");
1163 let dir = png_path.parent().unwrap();
1164 let source = dir.join("main.mos");
1165 std::fs::write(
1166 &source,
1167 "#set text(size: 20pt)\n#image(\"em.png\", width: 2em)\n",
1168 )
1169 .unwrap();
1170 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1171 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1172 let image_node = r
1173 .document
1174 .nodes()
1175 .find(|n| n.kind == NodeKind::Image)
1176 .expect("Image node");
1177 match image_node.attributes.get("width") {
1178 Some(AttrValue::Length(pt)) => assert!(
1179 (pt - 40.0).abs() < 0.01,
1180 "width = {pt}pt, expected 40pt (2em at 20pt)"
1181 ),
1182 other => panic!("expected width Length, got {other:?}"),
1183 }
1184 std::fs::remove_dir_all(dir).ok();
1185 }
1186
1187 #[test]
1188 fn missing_image_path_emits_mos0037() {
1189 let r = lower("#image()\n", &PathBuf::from("/tmp/no-such.mos"));
1190 assert!(
1191 r.diagnostics
1192 .iter()
1193 .any(|d| d.def().code() == codes::MOS0037.code()),
1194 "expected MOS0037, got {:?}",
1195 r.diagnostics
1196 );
1197 }
1198
1199 #[test]
1200 fn unreadable_image_emits_mos0012() {
1201 let r = lower(
1202 "#image(\"does-not-exist.png\")\n",
1203 &PathBuf::from("/tmp/no-such-dir/main.mos"),
1204 );
1205 assert!(
1206 r.diagnostics
1207 .iter()
1208 .any(|d| d.def().code() == codes::MOS0012.code()),
1209 "expected MOS0012, got {:?}",
1210 r.diagnostics
1211 );
1212 }
1213
1214 #[test]
1215 fn empty_image_path_emits_mos0037_not_io_error() {
1216 let r = lower("#image(\"\")\n", &PathBuf::from("/tmp/whatever/main.mos"));
1221 assert!(
1222 r.diagnostics
1223 .iter()
1224 .any(|d| d.def().code() == codes::MOS0037.code()),
1225 "expected MOS0037, got {:?}",
1226 r.diagnostics
1227 );
1228 assert!(
1230 !r.diagnostics.iter().any(|d| {
1231 d.def().code() == codes::MOS0012.code() || d.def().code() == codes::MOS0029.code()
1232 }),
1233 "unexpected I/O diagnostic: {:?}",
1234 r.diagnostics
1235 );
1236 }
1237
1238 #[test]
1239 fn non_positive_image_width_emits_mos0020() {
1240 for src in [
1244 "#image(\"x.png\", width: 0pt)\n",
1245 "#image(\"x.png\", width: -10pt)\n",
1246 "#image(\"x.png\", width: 0)\n",
1247 "#image(\"x.png\", width: -1)\n",
1248 ] {
1249 let r = lower(src, &PathBuf::from("/tmp/whatever/main.mos"));
1250 assert!(
1251 r.diagnostics
1252 .iter()
1253 .any(|d| d.def().code() == codes::MOS0020.code()),
1254 "expected MOS0020 for `{src}`, got {:?}",
1255 r.diagnostics
1256 );
1257 }
1258 }
1259
1260 #[test]
1261 fn non_positive_image_height_emits_mos0020() {
1262 for src in [
1263 "#image(\"x.png\", height: 0pt)\n",
1264 "#image(\"x.png\", height: -1mm)\n",
1265 ] {
1266 let r = lower(src, &PathBuf::from("/tmp/whatever/main.mos"));
1267 assert!(
1268 r.diagnostics
1269 .iter()
1270 .any(|d| d.def().code() == codes::MOS0020.code()),
1271 "expected MOS0020 for `{src}`, got {:?}",
1272 r.diagnostics
1273 );
1274 }
1275 }
1276
1277 #[test]
1278 fn undecodable_image_emits_mos0029() {
1279 let dir = std::env::temp_dir().join(format!(
1280 "mos-eval-bad-{}",
1281 std::time::SystemTime::now()
1282 .duration_since(std::time::UNIX_EPOCH)
1283 .map_or(0, |d| d.as_nanos())
1284 ));
1285 std::fs::create_dir_all(&dir).unwrap();
1286 let png = dir.join("bad.png");
1287 std::fs::write(&png, b"not really a PNG").unwrap();
1288 let source = dir.join("main.mos");
1289 std::fs::write(&source, "#image(\"bad.png\")\n").unwrap();
1290 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1291 assert!(
1292 r.diagnostics
1293 .iter()
1294 .any(|d| d.def().code() == codes::MOS0029.code()),
1295 "expected MOS0029, got {:?}",
1296 r.diagnostics
1297 );
1298 std::fs::remove_dir_all(&dir).ok();
1299 }
1300
1301 #[test]
1302 fn figure_directive_creates_figure_with_image_and_caption() {
1303 let png_path = write_tiny_png("fig.png");
1304 let source = png_path.parent().unwrap().join("main.mos");
1305 std::fs::write(
1306 &source,
1307 "#figure(image: \"fig.png\", caption: \"A tiny picture.\")\n",
1308 )
1309 .unwrap();
1310 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1311 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1312 let figure = r
1313 .document
1314 .nodes()
1315 .find(|n| n.kind == NodeKind::Figure)
1316 .expect("Figure node");
1317 assert_eq!(figure.children.len(), 2);
1318 let img = r.document.get(figure.children[0]).unwrap();
1319 assert_eq!(img.kind, NodeKind::Image);
1320 let caption = r.document.get(figure.children[1]).unwrap();
1321 assert_eq!(caption.kind, NodeKind::Paragraph);
1322 assert_eq!(
1323 caption.attributes.get("role"),
1324 Some(&AttrValue::Str("caption".to_owned()))
1325 );
1326 let caption_text = r.document.get(caption.children[0]).unwrap();
1329 assert_eq!(
1330 caption_text.attributes.get("text"),
1331 Some(&AttrValue::Str(
1332 "Figure\u{00A0}1: A tiny picture.".to_owned()
1333 ))
1334 );
1335 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1336 }
1337
1338 #[test]
1339 fn figure_with_missing_image_does_not_leak_empty_node() {
1340 let r = lower(
1347 "#figure(image: \"does-not-exist.png\", caption: \"missing\")\n",
1348 &PathBuf::from("/tmp/no-such-dir/main.mos"),
1349 );
1350 assert!(
1351 r.diagnostics
1352 .iter()
1353 .any(|d| d.def().code() == codes::MOS0012.code())
1354 );
1355 assert!(
1356 !r.document.nodes().any(|n| n.kind == NodeKind::Figure),
1357 "Figure node leaked after image load failure",
1358 );
1359 }
1360
1361 #[test]
1362 fn figure_label_reference_resolves_to_figure_number() {
1363 let png_path = write_tiny_png("ref-fig.png");
1369 let dir = png_path.parent().unwrap();
1370 let source = dir.join("main.mos");
1371 std::fs::write(
1372 &source,
1373 "#figure(image: \"ref-fig.png\", caption: \"A plot.\", label: \"fig:plot\")\n\nSee @fig:plot here.\n",
1374 )
1375 .unwrap();
1376 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1377 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1378
1379 let figure = r
1380 .document
1381 .nodes()
1382 .find(|n| n.kind == NodeKind::Figure)
1383 .expect("Figure node");
1384 assert_eq!(
1385 figure.attributes.get("number"),
1386 Some(&AttrValue::Str("1".to_owned())),
1387 "the lowered figure is numbered in document order"
1388 );
1389 assert_eq!(
1390 figure.attributes.get("label"),
1391 Some(&AttrValue::Str("fig:plot".to_owned())),
1392 "the `label:` argument lands on the Figure node"
1393 );
1394
1395 let caption_text = figure
1397 .children
1398 .iter()
1399 .filter_map(|c| r.document.get(*c))
1400 .find(|c| {
1401 matches!(c.attributes.get("role"), Some(AttrValue::Str(role)) if role == "caption")
1402 })
1403 .and_then(|caption| caption.children.first())
1404 .and_then(|text_id| r.document.get(*text_id))
1405 .and_then(|text| text.attributes.get("text"));
1406 assert_eq!(
1407 caption_text,
1408 Some(&AttrValue::Str("Figure\u{00A0}1: A plot.".to_owned())),
1409 "the caption is prefixed with the `Figure N: ` label"
1410 );
1411
1412 let reference = r
1413 .document
1414 .nodes()
1415 .find(|n| n.kind == NodeKind::Reference)
1416 .expect("Reference node");
1417 assert_eq!(
1418 reference.attributes.get("text"),
1419 Some(&AttrValue::Str("Figure\u{00A0}1".to_owned())),
1420 "the `@fig:plot` reference resolves to kind-aware figure text"
1421 );
1422
1423 std::fs::remove_dir_all(dir).ok();
1424 }
1425
1426 #[test]
1427 fn page_reference_lowers_to_inert_page_reference_node() {
1428 let r = lower(
1436 "= Intro <intro>\n\nSee @page(intro) here.\n",
1437 &PathBuf::from("test.mos"),
1438 );
1439 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1440
1441 let page_ref = r
1442 .document
1443 .nodes()
1444 .find(|n| n.kind == NodeKind::PageReference)
1445 .expect("PageReference node");
1446 assert_eq!(
1447 page_ref.attributes.get("label"),
1448 Some(&AttrValue::Str("intro".to_owned())),
1449 );
1450 assert_eq!(
1451 page_ref.attributes.get("text"),
1452 Some(&AttrValue::Str("?intro?".to_owned())),
1453 "unresolved page references keep a visible placeholder",
1454 );
1455 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1457 }
1458
1459 #[test]
1460 fn undeclared_page_reference_label_emits_mos0033() {
1461 let r = lower("See @page(missing) here.\n", &PathBuf::from("test.mos"));
1464 assert!(
1465 r.diagnostics
1466 .iter()
1467 .any(|d| d.def().code() == codes::MOS0033.code()),
1468 "{:?}",
1469 r.diagnostics
1470 );
1471 let page_ref = r
1473 .document
1474 .nodes()
1475 .find(|n| n.kind == NodeKind::PageReference)
1476 .expect("PageReference node");
1477 assert_eq!(
1478 page_ref.attributes.get("text"),
1479 Some(&AttrValue::Str("?missing?".to_owned())),
1480 );
1481 }
1482
1483 #[test]
1484 fn page_reference_to_a_declared_label_is_not_a_duplicate_declaration() {
1485 let r = lower(
1488 "= Intro <intro>\n\nSee @page(intro) here.\n",
1489 &PathBuf::from("test.mos"),
1490 );
1491 assert!(
1492 !r.diagnostics
1493 .iter()
1494 .any(|d| d.def().code() == codes::MOS0030.code()),
1495 "{:?}",
1496 r.diagnostics
1497 );
1498 }
1499
1500 #[test]
1501 fn citation_lowers_to_citation_node_with_key_and_span() {
1502 let src = "see [@smith2024] here\n";
1509 let r = lower(src, &PathBuf::from("test.mos"));
1510 assert!(
1511 r.diagnostics
1512 .iter()
1513 .any(|d| d.def().code() == codes::MOS0045.code()),
1514 "expected MOS0045 because no bibliography records are declared, got {:?}",
1515 r.diagnostics
1516 );
1517 let citation = r
1518 .document
1519 .nodes()
1520 .find(|n| n.kind == NodeKind::Citation)
1521 .expect("citation node");
1522 assert_eq!(
1523 citation.attributes.get("key"),
1524 Some(&AttrValue::Str("smith2024".to_owned())),
1525 );
1526 assert_eq!(
1527 citation.attributes.get("text"),
1528 Some(&AttrValue::Str("[?smith2024?]".to_owned())),
1529 );
1530 let span_text = &src[citation.span.start()..citation.span.end()];
1531 assert_eq!(span_text, "[@smith2024]");
1532 }
1533
1534 #[test]
1535 fn malformed_citation_does_not_create_citation_node() {
1536 let r = lower("look [@] here\n", &PathBuf::from("test.mos"));
1540 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1541 assert!(
1542 r.diagnostics
1543 .iter()
1544 .any(|d| d.def().code() == codes::MOS0039.code()),
1545 "expected MOS0039, got {:?}",
1546 r.diagnostics,
1547 );
1548 assert!(
1549 !r.document.nodes().any(|n| n.kind == NodeKind::Citation),
1550 "no Citation nodes expected, got {:?}",
1551 r.document.nodes().map(|n| n.kind).collect::<Vec<_>>(),
1552 );
1553 }
1554
1555 #[test]
1556 fn unterminated_citation_does_not_leak_into_reference_resolver() {
1557 let r = lower(
1565 "see [@smith2024 missing close\n",
1566 &PathBuf::from("test.mos"),
1567 );
1568 assert!(
1569 !r.has_errors(),
1570 "no errors expected, got {:?}",
1571 r.diagnostics,
1572 );
1573 assert!(
1574 r.diagnostics
1575 .iter()
1576 .any(|d| d.def().code() == codes::MOS0039.code()),
1577 "expected MOS0039, got {:?}",
1578 r.diagnostics,
1579 );
1580 assert!(
1581 !r.diagnostics
1582 .iter()
1583 .any(|d| d.def().code() == codes::MOS0033.code()),
1584 "malformed citation must not surface as unknown-label MOS0033: {:?}",
1585 r.diagnostics,
1586 );
1587 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Citation));
1588 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1589 }
1590
1591 #[test]
1592 fn deferred_multi_key_citation_does_not_leak_into_reference_resolver() {
1593 let r = lower(
1598 "compare [@smith2024; @jones2025] now\n",
1599 &PathBuf::from("test.mos"),
1600 );
1601 assert!(
1602 !r.has_errors(),
1603 "no errors expected, got {:?}",
1604 r.diagnostics,
1605 );
1606 assert!(
1607 r.diagnostics
1608 .iter()
1609 .any(|d| d.def().code() == codes::MOS0039.code())
1610 );
1611 assert!(
1612 !r.diagnostics
1613 .iter()
1614 .any(|d| d.def().code() == codes::MOS0033.code()),
1615 "multi-key citation must not surface as unknown-label MOS0033: {:?}",
1616 r.diagnostics,
1617 );
1618 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Citation));
1619 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1620 }
1621
1622 #[test]
1623 fn figure_directive_accepts_positional_path() {
1624 let png_path = write_tiny_png("fig_pos.png");
1628 let source = png_path.parent().unwrap().join("main.mos");
1629 std::fs::write(&source, "#figure(\"fig_pos.png\")\n").unwrap();
1630 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1631 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1632 let figure = r
1633 .document
1634 .nodes()
1635 .find(|n| n.kind == NodeKind::Figure)
1636 .expect("Figure node");
1637 assert_eq!(figure.children.len(), 1);
1639 let img = r.document.get(figure.children[0]).unwrap();
1640 assert_eq!(img.kind, NodeKind::Image);
1641 assert_eq!(
1642 img.attributes.get("src"),
1643 Some(&AttrValue::Str("fig_pos.png".to_owned()))
1644 );
1645 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1646 }
1647
1648 fn unique_temp_dir(name: &str) -> PathBuf {
1652 let dir = std::env::temp_dir().join(format!(
1653 "mos-eval-bib-{}-{}",
1654 name,
1655 std::time::SystemTime::now()
1656 .duration_since(std::time::UNIX_EPOCH)
1657 .map_or(0, |d| d.as_nanos())
1658 ));
1659 std::fs::create_dir_all(&dir).unwrap();
1660 dir
1661 }
1662
1663 #[test]
1664 fn bibliography_directive_preserves_resolved_path() {
1665 let dir = unique_temp_dir("preserve");
1670 let bib = dir.join("refs.bib");
1671 std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1672 let source = dir.join("main.mos");
1673 std::fs::write(&source, "#bibliography(\"refs.bib\")\n").unwrap();
1674 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1675 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1676 let node = r
1677 .document
1678 .nodes()
1679 .find(|n| n.kind == NodeKind::Bibliography)
1680 .expect("Bibliography node");
1681 assert_eq!(
1682 node.attributes.get("src"),
1683 Some(&AttrValue::Str("refs.bib".to_owned()))
1684 );
1685 assert_eq!(
1686 node.attributes.get("resolved_path"),
1687 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1688 );
1689 std::fs::remove_dir_all(&dir).ok();
1690 }
1691
1692 #[test]
1693 fn lowering_exposes_the_loaded_bibliography_records() {
1694 let dir = unique_temp_dir("records");
1695 std::fs::write(dir.join("a.bib"), "@book{alpha, title={A}}\n").unwrap();
1696 std::fs::write(dir.join("b.bib"), "@article{beta, title={B}}\n").unwrap();
1697 let source = dir.join("main.mos");
1698 let r = lower(
1699 "#bibliography(\"a.bib\")\n\n#bibliography(\"b.bib\")\n\n#bibliography(\"missing.bib\")\n",
1700 &source,
1701 );
1702 let keys: Vec<&str> = r.bibliography.entries.keys().map(String::as_str).collect();
1703 assert_eq!(keys, ["alpha", "beta"]);
1704 assert_eq!(r.bibliography.entries["beta"].entry_type, "article");
1705 assert!(!r.bibliography_complete);
1706 assert!(
1707 r.diagnostics
1708 .iter()
1709 .any(|d| d.def().code() == codes::MOS0041.code()),
1710 "{:?}",
1711 r.diagnostics
1712 );
1713 std::fs::write(dir.join("missing.bib"), "@book{gamma, title={C}}\n").unwrap();
1714 let complete = lower(
1715 "#bibliography(\"a.bib\")\n\n#bibliography(\"b.bib\")\n\n#bibliography(\"missing.bib\")\n",
1716 &source,
1717 );
1718 assert!(complete.bibliography_complete);
1719 assert_eq!(complete.bibliography.entries.len(), 3);
1720 std::fs::remove_dir_all(&dir).ok();
1721 }
1722
1723 #[test]
1724 fn bibliography_named_path_resolves_against_source_dir() {
1725 let dir = unique_temp_dir("named");
1728 let sub = dir.join("sources");
1729 std::fs::create_dir_all(&sub).unwrap();
1730 let bib = sub.join("refs.bib");
1731 std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1732 let source = dir.join("main.mos");
1733 std::fs::write(&source, "#bibliography(path: \"sources/refs.bib\")\n").unwrap();
1734 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1735 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1736 let node = r
1737 .document
1738 .nodes()
1739 .find(|n| n.kind == NodeKind::Bibliography)
1740 .expect("Bibliography node");
1741 assert_eq!(
1742 node.attributes.get("resolved_path"),
1743 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1744 );
1745 std::fs::remove_dir_all(&dir).ok();
1746 }
1747
1748 #[test]
1749 fn bibliography_src_alias_resolves_against_source_dir() {
1750 let dir = unique_temp_dir("src-alias");
1753 let sub = dir.join("sources");
1754 std::fs::create_dir_all(&sub).unwrap();
1755 let bib = sub.join("refs.bib");
1756 std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1757 let source = dir.join("main.mos");
1758 std::fs::write(&source, "#bibliography(src: \"sources/refs.bib\")\n").unwrap();
1759 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1760 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1761 let node = r
1762 .document
1763 .nodes()
1764 .find(|n| n.kind == NodeKind::Bibliography)
1765 .expect("Bibliography node");
1766 assert_eq!(
1767 node.attributes.get("src"),
1768 Some(&AttrValue::Str("sources/refs.bib".to_owned()))
1769 );
1770 assert_eq!(
1771 node.attributes.get("resolved_path"),
1772 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1773 );
1774 std::fs::remove_dir_all(&dir).ok();
1775 }
1776
1777 #[test]
1778 fn known_citation_key_resolves_against_bibliography_records() {
1779 let dir = unique_temp_dir("citation-known");
1783 let bib = dir.join("refs.bib");
1784 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
1785 let source = dir.join("main.mos");
1786 let source_text =
1787 "#bibliography(\"refs.bib\")\n\n= Intro <intro>\n\nsee [@smith2024] and @intro\n";
1788 std::fs::write(&source, source_text).unwrap();
1789
1790 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1791 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1792
1793 let citation = r
1794 .document
1795 .nodes()
1796 .find(|n| n.kind == NodeKind::Citation)
1797 .expect("Citation node");
1798 assert_eq!(
1799 citation.attributes.get("resolved"),
1800 Some(&AttrValue::Bool(true)),
1801 "known key should be marked resolved for later rendering"
1802 );
1803 assert_eq!(
1804 citation.attributes.get("text"),
1805 Some(&AttrValue::Str("[1]".to_owned())),
1806 "a resolved citation renders its first-use numeric label"
1807 );
1808 assert_eq!(
1809 citation.attributes.get("target_path"),
1810 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1811 );
1812 assert_eq!(
1813 citation.attributes.get("target_span.start"),
1814 Some(&AttrValue::Int(9))
1815 );
1816 assert_eq!(
1817 citation.attributes.get("target_span.end"),
1818 Some(&AttrValue::Int(18))
1819 );
1820
1821 let reference = r
1822 .document
1823 .nodes()
1824 .find(|n| n.kind == NodeKind::Reference)
1825 .expect("Reference node");
1826 assert_eq!(
1827 reference.attributes.get("text"),
1828 Some(&AttrValue::Str("1".to_owned())),
1829 "label references still resolve while citations are checked"
1830 );
1831
1832 std::fs::remove_dir_all(&dir).ok();
1833 }
1834
1835 fn bibliography_entries(document: &Document) -> Vec<(i64, String, String)> {
1838 let bib = document
1839 .nodes()
1840 .find(|n| n.kind == NodeKind::Bibliography)
1841 .expect("Bibliography node");
1842 bib.children
1843 .iter()
1844 .filter_map(|id| document.get(*id))
1845 .map(|entry| {
1846 let Some(AttrValue::Int(number)) = entry.attributes.get("entry_number") else {
1847 panic!("entry paragraph without entry_number: {entry:?}");
1848 };
1849 let Some(AttrValue::Str(key)) = entry.attributes.get("entry_key") else {
1850 panic!("entry paragraph without entry_key: {entry:?}");
1851 };
1852 let text = entry
1853 .children
1854 .iter()
1855 .filter_map(|id| document.get(*id))
1856 .find_map(|child| match child.attributes.get("text") {
1857 Some(AttrValue::Str(text)) => Some(text.clone()),
1858 _ => None,
1859 })
1860 .expect("entry paragraph without a Text child");
1861 (*number, key.clone(), text)
1862 })
1863 .collect()
1864 }
1865
1866 #[test]
1867 fn cited_entries_render_in_first_use_order() {
1868 let dir = unique_temp_dir("entries-order");
1872 std::fs::write(
1873 dir.join("refs.bib"),
1874 "@book{a, title={Alpha}, author={Ada Author}, year={1990}}\n\
1875 @book{b, title={Beta}, publisher={Beta House}, year={1991}}\n",
1876 )
1877 .unwrap();
1878 let source = dir.join("main.mos");
1879 std::fs::write(
1880 &source,
1881 "#bibliography(\"refs.bib\")\n\nsee [@b] then [@a]\n",
1882 )
1883 .unwrap();
1884
1885 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1886 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1887
1888 let entries = bibliography_entries(&r.document);
1889 assert_eq!(entries.len(), 2, "{entries:?}");
1890 assert_eq!(entries[0].0, 1);
1891 assert_eq!(entries[0].1, "b");
1892 assert_eq!(entries[0].2, "Beta. Beta House. 1991.");
1893 assert_eq!(entries[1].0, 2);
1894 assert_eq!(entries[1].1, "a");
1895 assert_eq!(entries[1].2, "Ada Author. Alpha. 1990.");
1896
1897 std::fs::remove_dir_all(&dir).ok();
1898 }
1899
1900 #[test]
1901 fn repeated_and_uncited_keys_render_one_entry_each_or_none() {
1902 let dir = unique_temp_dir("entries-dedup");
1906 std::fs::write(
1907 dir.join("refs.bib"),
1908 "@book{a, title={Alpha}}\n@book{b, title={Beta}}\n",
1909 )
1910 .unwrap();
1911 let source = dir.join("main.mos");
1912 std::fs::write(
1913 &source,
1914 "#bibliography(\"refs.bib\")\n\nsee [@a] and [@a] again\n",
1915 )
1916 .unwrap();
1917
1918 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1919 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1920
1921 let entries = bibliography_entries(&r.document);
1922 assert_eq!(entries.len(), 1, "{entries:?}");
1923 assert_eq!(entries[0], (1, "a".to_owned(), "Alpha.".to_owned()));
1924
1925 std::fs::remove_dir_all(&dir).ok();
1926 }
1927
1928 #[test]
1929 fn unresolved_keys_produce_no_bibliography_entries() {
1930 let dir = unique_temp_dir("entries-unresolved");
1933 std::fs::write(dir.join("refs.bib"), "@book{real, title={Real}}\n").unwrap();
1934 let source = dir.join("main.mos");
1935 std::fs::write(&source, "#bibliography(\"refs.bib\")\n\nsee [@phantom]\n").unwrap();
1936
1937 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1938 assert!(
1939 r.diagnostics
1940 .iter()
1941 .any(|d| d.def().code() == codes::MOS0045.code()),
1942 "unknown key keeps MOS0045: {:?}",
1943 r.diagnostics
1944 );
1945
1946 let entries = bibliography_entries(&r.document);
1947 assert!(entries.is_empty(), "{entries:?}");
1948
1949 std::fs::remove_dir_all(&dir).ok();
1950 }
1951
1952 fn mos0049_diagnostic(diagnostics: &[Diagnostic]) -> &Diagnostic {
1953 let found: Vec<&Diagnostic> = diagnostics
1954 .iter()
1955 .filter(|d| d.def().code() == codes::MOS0049.code())
1956 .collect();
1957 assert_eq!(found.len(), 1, "exactly one MOS0049, got {found:?}");
1958 found[0]
1959 }
1960
1961 #[test]
1962 fn unsafe_named_image_and_bibliography_paths_suggest_forward_slashes() {
1963 let source_text = "#image(src: \"a\\\\b.png\")\n#bibliography(path: \"c\\\\d.bib\")\n";
1964 let r = lower(source_text, &PathBuf::from("test.mos"));
1965 let fixes: Vec<(String, &str)> = r
1966 .diagnostics
1967 .iter()
1968 .filter(|d| d.def().code() == codes::MOS0049.code())
1969 .flat_map(|d| d.suggestions().iter())
1970 .map(|s| {
1971 (
1972 s.replacement.clone(),
1973 &source_text[s.span.start()..s.span.end()],
1974 )
1975 })
1976 .collect();
1977 assert_eq!(
1978 fixes,
1979 vec![
1980 ("a/b.png".to_owned(), r"a\\b.png"),
1981 ("c/d.bib".to_owned(), r"c\\d.bib"),
1982 ]
1983 );
1984 }
1985
1986 #[test]
1987 fn unsafe_path_fix_re_escapes_the_literal_contents() {
1988 let source_text = "#image(\"a\\\"b\\\\c.png\")\n";
1989 let r = lower(source_text, &PathBuf::from("test.mos"));
1990 let diag = mos0049_diagnostic(&r.diagnostics);
1991 let suggestions = diag.suggestions();
1992 assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
1993 assert_eq!(suggestions[0].replacement, r#"a\"b/c.png"#);
1994 assert_eq!(
1995 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
1996 r#"a\"b\\c.png"#
1997 );
1998 }
1999
2000 #[test]
2001 fn unsafe_image_path_suggests_forward_slashes() {
2002 let source_text = "#image(\"assets\\\\logo.png\")\n";
2003 let r = lower(source_text, &PathBuf::from("test.mos"));
2004 let diag = mos0049_diagnostic(&r.diagnostics);
2005 let suggestions = diag.suggestions();
2006 assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2007 assert_eq!(suggestions[0].replacement, "assets/logo.png");
2008 assert_eq!(
2009 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2010 r"assets\\logo.png",
2011 "the fix replaces the literal's contents, quotes excluded"
2012 );
2013 }
2014
2015 #[test]
2016 fn unsafe_figure_image_path_suggests_forward_slashes() {
2017 let source_text = "#figure(image: \"img\\\\a.png\", caption: \"c\")\n";
2018 let r = lower(source_text, &PathBuf::from("test.mos"));
2019 let diag = mos0049_diagnostic(&r.diagnostics);
2020 let suggestions = diag.suggestions();
2021 assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2022 assert_eq!(suggestions[0].replacement, "img/a.png");
2023 assert_eq!(
2024 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2025 r"img\\a.png"
2026 );
2027 }
2028
2029 #[test]
2030 fn unsafe_bibliography_path_suggests_forward_slashes() {
2031 let source_text = "#bibliography(\"refs\\\\x.bib\")\n";
2032 let r = lower(source_text, &PathBuf::from("test.mos"));
2033 let diag = mos0049_diagnostic(&r.diagnostics);
2034 let suggestions = diag.suggestions();
2035 assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2036 assert_eq!(suggestions[0].replacement, "refs/x.bib");
2037 assert_eq!(
2038 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2039 r"refs\\x.bib"
2040 );
2041 }
2042
2043 #[test]
2044 fn drive_prefixed_image_path_gets_no_suggestion() {
2045 let source_text = "#image(\"C:\\\\x.png\")\n";
2046 let r = lower(source_text, &PathBuf::from("test.mos"));
2047 let diag = mos0049_diagnostic(&r.diagnostics);
2048 assert!(
2049 diag.suggestions().is_empty(),
2050 "no guess for a drive-prefixed path, got {:?}",
2051 diag.suggestions()
2052 );
2053 }
2054
2055 #[test]
2056 fn label_reference_matching_bib_key_suggests_citation() {
2057 let dir = unique_temp_dir("ref-is-bibkey");
2061 let bib = dir.join("refs.bib");
2062 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2063 let source = dir.join("main.mos");
2064 let source_text = "#bibliography(\"refs.bib\")\n\nsee @smith2024 here\n";
2065 std::fs::write(&source, source_text).unwrap();
2066
2067 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2068
2069 let diag = r
2070 .diagnostics
2071 .iter()
2072 .find(|d| d.def().code() == codes::MOS0033.code())
2073 .expect("MOS0033 for the @smith2024 reference");
2074 let suggestions = diag.suggestions();
2075 assert_eq!(
2076 suggestions.len(),
2077 1,
2078 "one citation fix, got {suggestions:?}"
2079 );
2080 assert_eq!(suggestions[0].replacement, "[@smith2024]");
2081 assert_eq!(
2082 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2083 "@smith2024",
2084 "the fix replaces the whole `@key` token, sigil included"
2085 );
2086
2087 std::fs::remove_dir_all(&dir).ok();
2088 }
2089
2090 #[test]
2091 fn repeated_known_citation_key_reuses_its_first_number() {
2092 let dir = unique_temp_dir("citation-repeat");
2095 let bib = dir.join("refs.bib");
2096 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2097 let source = dir.join("main.mos");
2098 let source_text =
2099 "#bibliography(\"refs.bib\")\n\nsee [@smith2024] and again [@smith2024]\n";
2100 std::fs::write(&source, source_text).unwrap();
2101
2102 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2103 assert!(!r.has_errors(), "{:?}", r.diagnostics);
2104
2105 let labels: Vec<Option<AttrValue>> = r
2106 .document
2107 .nodes()
2108 .filter(|n| n.kind == NodeKind::Citation)
2109 .map(|n| n.attributes.get("text").cloned())
2110 .collect();
2111 assert_eq!(
2112 labels,
2113 vec![
2114 Some(AttrValue::Str("[1]".to_owned())),
2115 Some(AttrValue::Str("[1]".to_owned())),
2116 ],
2117 "repeated key reuses its first-use number"
2118 );
2119
2120 std::fs::remove_dir_all(&dir).ok();
2121 }
2122
2123 #[test]
2124 fn distinct_known_citation_keys_number_by_first_use_order() {
2125 let dir = unique_temp_dir("citation-order");
2129 let bib = dir.join("refs.bib");
2130 std::fs::write(
2132 &bib,
2133 "@article{alpha, title={A}}\n@article{beta, title={B}}\n",
2134 )
2135 .unwrap();
2136 let source = dir.join("main.mos");
2137 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@beta] then [@alpha] and [@beta]\n";
2139 std::fs::write(&source, source_text).unwrap();
2140
2141 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2142 assert!(!r.has_errors(), "{:?}", r.diagnostics);
2143
2144 let labels: Vec<Option<AttrValue>> = r
2145 .document
2146 .nodes()
2147 .filter(|n| n.kind == NodeKind::Citation)
2148 .map(|n| n.attributes.get("text").cloned())
2149 .collect();
2150 assert_eq!(
2151 labels,
2152 vec![
2153 Some(AttrValue::Str("[1]".to_owned())),
2154 Some(AttrValue::Str("[2]".to_owned())),
2155 Some(AttrValue::Str("[1]".to_owned())),
2156 ],
2157 "numbering follows first citation, not bibliography source order"
2158 );
2159
2160 std::fs::remove_dir_all(&dir).ok();
2161 }
2162
2163 #[test]
2164 fn unknown_citation_key_emits_mos0045_with_source_span() {
2165 let dir = unique_temp_dir("citation-unknown");
2166 let bib = dir.join("refs.bib");
2167 std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
2168 let source = dir.join("main.mos");
2169 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@missing]\n";
2170 std::fs::write(&source, source_text).unwrap();
2171
2172 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2173 let missing: Vec<&Diagnostic> = r
2174 .diagnostics
2175 .iter()
2176 .filter(|d| d.def().code() == codes::MOS0045.code())
2177 .collect();
2178 assert_eq!(
2179 missing.len(),
2180 1,
2181 "expected one MOS0045, got {:?}",
2182 r.diagnostics
2183 );
2184 let diagnostic = missing[0];
2185 assert!(
2186 diagnostic.message().contains("`missing`"),
2187 "diagnostic should name missing citation key, got {:?}",
2188 diagnostic.message()
2189 );
2190 assert_eq!(
2191 diagnostic
2192 .span()
2193 .map(|span| &source_text[span.start()..span.end()]),
2194 Some("[@missing]"),
2195 "MOS0045 should point at the citation token"
2196 );
2197
2198 let citation = r
2199 .document
2200 .nodes()
2201 .find(|n| n.kind == NodeKind::Citation)
2202 .expect("Citation node");
2203 assert_eq!(
2204 citation.attributes.get("text"),
2205 Some(&AttrValue::Str("[?missing?]".to_owned())),
2206 "unknown citations keep visible placeholder text"
2207 );
2208 assert_eq!(citation.attributes.get("resolved"), None);
2209
2210 std::fs::remove_dir_all(&dir).ok();
2211 }
2212
2213 #[test]
2214 fn unknown_citation_key_suggests_nearest_loaded_key() {
2215 let dir = unique_temp_dir("citation-nearest-key");
2216 let bib = dir.join("refs.bib");
2217 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2218 let source = dir.join("main.mos");
2219 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@smit2024]\n";
2220 std::fs::write(&source, source_text).unwrap();
2221
2222 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2223 let diagnostic = r
2224 .diagnostics
2225 .iter()
2226 .find(|d| d.def().code() == codes::MOS0045.code())
2227 .expect("MOS0045 for missing citation key");
2228 let suggestions = diagnostic.suggestions();
2229 assert_eq!(
2230 suggestions.len(),
2231 1,
2232 "expected one nearest-key suggestion, got {suggestions:?}"
2233 );
2234 assert_eq!(suggestions[0].replacement, "smith2024");
2235 assert_eq!(
2236 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2237 "smit2024",
2238 "suggestion should replace only the citation key token"
2239 );
2240
2241 std::fs::remove_dir_all(&dir).ok();
2242 }
2243
2244 #[test]
2245 fn unknown_citation_key_tie_has_no_suggestion() {
2246 let dir = unique_temp_dir("citation-nearest-key-tie");
2247 let bib = dir.join("refs.bib");
2248 std::fs::write(&bib, "@article{abx, title={X}}\n@article{aby, title={Y}}\n").unwrap();
2249 let source = dir.join("main.mos");
2250 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@abc]\n";
2251 std::fs::write(&source, source_text).unwrap();
2252
2253 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2254 let diagnostic = r
2255 .diagnostics
2256 .iter()
2257 .find(|d| d.def().code() == codes::MOS0045.code())
2258 .expect("MOS0045 for missing citation key");
2259 assert!(
2260 diagnostic.suggestions().is_empty(),
2261 "ties should not produce a guess: {:?}",
2262 diagnostic.suggestions()
2263 );
2264
2265 std::fs::remove_dir_all(&dir).ok();
2266 }
2267
2268 #[test]
2269 fn multiple_unknown_citations_emit_deterministic_mos0045_diagnostics() {
2270 let dir = unique_temp_dir("citation-multiple-unknown");
2271 let bib = dir.join("refs.bib");
2272 std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
2273 let source = dir.join("main.mos");
2274 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@alpha] and [@beta]\n";
2275 std::fs::write(&source, source_text).unwrap();
2276
2277 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2278 let spans: Vec<&str> = r
2279 .diagnostics
2280 .iter()
2281 .filter(|d| d.def().code() == codes::MOS0045.code())
2282 .filter_map(|d| d.span().map(|span| &source_text[span.start()..span.end()]))
2283 .collect();
2284 assert_eq!(
2285 spans,
2286 vec!["[@alpha]", "[@beta]"],
2287 "unknown citation diagnostics should follow document order"
2288 );
2289
2290 std::fs::remove_dir_all(&dir).ok();
2291 }
2292
2293 #[test]
2294 fn incomplete_bibliography_sources_do_not_emit_false_missing_citations() {
2295 let dir = unique_temp_dir("citation-incomplete-bibliography");
2296 let bib = dir.join("refs.bib");
2297 std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
2298 let source = dir.join("main.mos");
2299 let source_text = "#bibliography(\"refs.bib\")\n#bibliography(\"missing.bib\")\n\nsee [@known] and [@maybe-in-missing]\n";
2300 std::fs::write(&source, source_text).unwrap();
2301
2302 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2303 assert!(
2304 r.diagnostics
2305 .iter()
2306 .any(|d| d.def().code() == codes::MOS0041.code()),
2307 "expected missing bibliography source warning, got {:?}",
2308 r.diagnostics
2309 );
2310 assert!(
2311 !r.diagnostics
2312 .iter()
2313 .any(|d| d.def().code() == codes::MOS0045.code()),
2314 "incomplete bibliography set must not produce false MOS0045 diagnostics"
2315 );
2316
2317 let known = r
2318 .document
2319 .nodes()
2320 .filter(|n| n.kind == NodeKind::Citation)
2321 .find(|n| n.attributes.get("key") == Some(&AttrValue::Str("known".to_owned())))
2322 .expect("known citation node");
2323 assert_eq!(
2324 known.attributes.get("resolved"),
2325 Some(&AttrValue::Bool(true))
2326 );
2327
2328 std::fs::remove_dir_all(&dir).ok();
2329 }
2330
2331 #[test]
2332 fn duplicate_citation_keys_across_bibliography_sources_emit_mos0046() {
2333 let dir = unique_temp_dir("citation-duplicate-key");
2334 let first = dir.join("first.bib");
2335 let second = dir.join("second.bib");
2336 std::fs::write(&first, "@article{dup, title={First}}\n").unwrap();
2337 std::fs::write(&second, "@book{dup, title={Second}}\n").unwrap();
2338 let source = dir.join("main.mos");
2339 let source_text =
2340 "#bibliography(\"first.bib\")\n#bibliography(\"second.bib\")\n\nsee [@dup]\n";
2341 std::fs::write(&source, source_text).unwrap();
2342
2343 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2344 let duplicates: Vec<&Diagnostic> = r
2345 .diagnostics
2346 .iter()
2347 .filter(|d| d.def().code() == codes::MOS0046.code())
2348 .collect();
2349 assert_eq!(
2350 duplicates.len(),
2351 1,
2352 "expected one MOS0046, got {:?}",
2353 r.diagnostics
2354 );
2355 let diagnostic = duplicates[0];
2356 assert!(
2357 diagnostic.message().contains("`dup`"),
2358 "diagnostic should name duplicate citation key, got {:?}",
2359 diagnostic.message()
2360 );
2361 assert_eq!(
2362 diagnostic
2363 .span()
2364 .map(|span| &source_text[span.start()..span.end()]),
2365 Some("#bibliography(\"second.bib\")"),
2366 "duplicate should point at the later bibliography source"
2367 );
2368
2369 std::fs::remove_dir_all(&dir).ok();
2370 }
2371
2372 #[test]
2373 fn missing_bibliography_path_emits_mos0040() {
2374 let r = lower("#bibliography()\n", &PathBuf::from("/tmp/no-such.mos"));
2377 assert!(
2378 r.diagnostics
2379 .iter()
2380 .any(|d| d.def().code() == codes::MOS0040.code()),
2381 "expected MOS0040, got {:?}",
2382 r.diagnostics
2383 );
2384 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Bibliography));
2385 }
2386
2387 #[test]
2388 fn empty_bibliography_path_emits_mos0040() {
2389 let r = lower(
2392 "#bibliography(\"\")\n",
2393 &PathBuf::from("/tmp/whatever/main.mos"),
2394 );
2395 assert!(
2396 r.diagnostics
2397 .iter()
2398 .any(|d| d.def().code() == codes::MOS0040.code()),
2399 "expected MOS0040, got {:?}",
2400 r.diagnostics
2401 );
2402 assert!(
2403 !r.diagnostics
2404 .iter()
2405 .any(|d| d.def().code() == codes::MOS0041.code()),
2406 "empty path must not trip the filesystem warning: {:?}",
2407 r.diagnostics
2408 );
2409 }
2410
2411 #[test]
2412 fn non_string_bibliography_path_emits_type_mismatch_only() {
2413 let r = lower(
2416 "#bibliography(src: 12pt)\n",
2417 &PathBuf::from("/tmp/whatever/main.mos"),
2418 );
2419 assert!(
2420 r.diagnostics
2421 .iter()
2422 .any(|d| d.def().code() == codes::MOS0020.code()),
2423 "expected MOS0020, got {:?}",
2424 r.diagnostics
2425 );
2426 assert!(
2427 !r.diagnostics
2428 .iter()
2429 .any(|d| d.def().code() == codes::MOS0040.code()),
2430 "non-string path must not also emit MOS0040: {:?}",
2431 r.diagnostics
2432 );
2433 assert!(
2434 !r.diagnostics
2435 .iter()
2436 .any(|d| d.def().code() == codes::MOS0041.code()),
2437 "non-string path must not reach filesystem warning: {:?}",
2438 r.diagnostics
2439 );
2440 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Bibliography));
2441 }
2442
2443 #[test]
2444 fn duplicate_bibliography_path_keeps_first_path() {
2445 let dir = unique_temp_dir("duplicate-path");
2449 let first = dir.join("first.bib");
2450 let second = dir.join("second.bib");
2451 std::fs::write(&first, "@book{{first}}\n").unwrap();
2452 std::fs::write(&second, "@book{{second}}\n").unwrap();
2453 let source = dir.join("main.mos");
2454 let source_text = "#bibliography(\"first.bib\", path: \"second.bib\")\n";
2455 std::fs::write(&source, source_text).unwrap();
2456 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2457 let duplicate_path_diagnostics: Vec<&Diagnostic> = r
2458 .diagnostics
2459 .iter()
2460 .filter(|d| d.def().code() == codes::MOS0042.code())
2461 .collect();
2462 assert_eq!(
2463 duplicate_path_diagnostics.len(),
2464 1,
2465 "expected one MOS0042, got {:?}",
2466 r.diagnostics
2467 );
2468 let duplicate = duplicate_path_diagnostics[0];
2469 assert_eq!(
2470 duplicate
2471 .span()
2472 .map(|span| &source_text[span.start()..span.end()]),
2473 Some("\"second.bib\""),
2474 "duplicate path diagnostic should point at the later path value"
2475 );
2476 let node = r
2477 .document
2478 .nodes()
2479 .find(|n| n.kind == NodeKind::Bibliography)
2480 .expect("Bibliography node");
2481 assert_eq!(
2482 node.attributes.get("src"),
2483 Some(&AttrValue::Str("first.bib".to_owned()))
2484 );
2485 assert_eq!(
2486 node.attributes.get("resolved_path"),
2487 Some(&AttrValue::Str(first.to_string_lossy().into_owned()))
2488 );
2489 std::fs::remove_dir_all(&dir).ok();
2490 }
2491
2492 #[test]
2493 fn missing_bibliography_source_warns_mos0041_but_keeps_node() {
2494 let dir = unique_temp_dir("absent");
2498 let source = dir.join("main.mos");
2499 std::fs::write(&source, "#bibliography(\"nope.bib\")\n").unwrap();
2500 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2501 assert!(
2502 !r.has_errors(),
2503 "a missing source is a warning, not an error: {:?}",
2504 r.diagnostics
2505 );
2506 assert!(
2507 r.diagnostics
2508 .iter()
2509 .any(|d| d.def().code() == codes::MOS0041.code()),
2510 "expected MOS0041, got {:?}",
2511 r.diagnostics
2512 );
2513 let node = r
2514 .document
2515 .nodes()
2516 .find(|n| n.kind == NodeKind::Bibliography)
2517 .expect("Bibliography node still emitted on a missing source");
2518 assert_eq!(
2519 node.attributes.get("resolved_path"),
2520 Some(&AttrValue::Str(
2521 dir.join("nope.bib").to_string_lossy().into_owned()
2522 ))
2523 );
2524 std::fs::remove_dir_all(&dir).ok();
2525 }
2526
2527 #[test]
2528 fn unknown_bibliography_arg_emits_mos0015() {
2529 let dir = unique_temp_dir("unknownarg");
2533 std::fs::write(dir.join("refs.bib"), "@book{a}\n").unwrap();
2534 let source = dir.join("main.mos");
2535 std::fs::write(&source, "#bibliography(\"refs.bib\", style: \"ieee\")\n").unwrap();
2536 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2537 assert!(
2538 r.diagnostics
2539 .iter()
2540 .any(|d| d.def().code() == codes::MOS0015.code()),
2541 "expected MOS0015, got {:?}",
2542 r.diagnostics
2543 );
2544 std::fs::remove_dir_all(&dir).ok();
2545 }
2546
2547 fn find_unknown_arg_diagnostic(diagnostics: &[Diagnostic]) -> &Diagnostic {
2550 diagnostics
2551 .iter()
2552 .find(|d| d.def().code() == codes::MOS0015.code())
2553 .expect("expected an MOS0015 unknown-argument diagnostic")
2554 }
2555
2556 #[test]
2557 fn image_unknown_arg_close_typo_suggests_key() {
2558 let src = "#image(\"x.png\", wdith: 100pt)\n";
2561 let r = lower(src, &PathBuf::from("test.mos"));
2562 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2563 let suggestions = d.suggestions();
2564 assert_eq!(
2565 suggestions.len(),
2566 1,
2567 "expected one nearest-key suggestion, got {suggestions:?}"
2568 );
2569 assert_eq!(suggestions[0].replacement, "width");
2570 assert_eq!(
2571 &src[suggestions[0].span.start()..suggestions[0].span.end()],
2572 "wdith",
2573 "fix must replace only the key token"
2574 );
2575 }
2576
2577 #[test]
2578 fn image_unknown_arg_far_name_has_no_suggestion() {
2579 let r = lower("#image(\"x.png\", zzz: 1)\n", &PathBuf::from("test.mos"));
2582 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2583 assert!(
2584 d.suggestions().is_empty(),
2585 "an unrelated key must not be guessed, got {:?}",
2586 d.suggestions()
2587 );
2588 }
2589
2590 #[test]
2591 fn figure_unknown_arg_close_typo_suggests_key() {
2592 let src = "#figure(image: \"x.png\", captoin: \"hi\")\n";
2593 let r = lower(src, &PathBuf::from("test.mos"));
2594 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2595 let suggestions = d.suggestions();
2596 assert_eq!(
2597 suggestions.len(),
2598 1,
2599 "expected one nearest-key suggestion, got {suggestions:?}"
2600 );
2601 assert_eq!(suggestions[0].replacement, "caption");
2602 assert_eq!(
2603 &src[suggestions[0].span.start()..suggestions[0].span.end()],
2604 "captoin",
2605 "fix must replace only the key token"
2606 );
2607 }
2608
2609 #[test]
2610 fn bibliography_unknown_arg_close_typo_suggests_key() {
2611 let src = "#bibliography(\"refs.bib\", pth: \"x\")\n";
2612 let r = lower(src, &PathBuf::from("test.mos"));
2613 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2614 let suggestions = d.suggestions();
2615 assert_eq!(
2616 suggestions.len(),
2617 1,
2618 "expected one nearest-key suggestion, got {suggestions:?}"
2619 );
2620 assert_eq!(suggestions[0].replacement, "path");
2621 assert_eq!(
2622 &src[suggestions[0].span.start()..suggestions[0].span.end()],
2623 "pth",
2624 "fix must replace only the key token"
2625 );
2626 }
2627}