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, FileSystemReader, Fingerprint, RACY_WINDOW, ResourceData,
43 ResourceFingerprint, ResourceReader, ResourceSnapshot, fingerprint_bytes, fingerprint_file,
44};
45pub use mos_bib::Bibliography;
46pub use pageref::{PageFixpointOutcome, resolve_page_reference_fixpoint, resolve_page_references};
47pub use resolve::resolve;
48
49use bibliography::{load_bibliography, lower_bibliography_directive, resolve_citations};
50use dependency::ExternalInputs;
51use image_lower::{lower_figure_directive, lower_image_directive};
52use inline::lower_inlines;
53use list::lower as lower_list;
54use set::lower_set_directive;
55
56const LABEL_SPAN_START_ATTR: &str = "label_span.start";
57const LABEL_SPAN_END_ATTR: &str = "label_span.end";
58pub const DOC_ATTR: &str = "doc";
61pub const CODE_LANGUAGE_ATTR: &str = "raw.lang";
63
64fn insert_label_attributes(attributes: &mut AttrMap, label: &str, label_span: Option<&SourceSpan>) {
65 attributes.insert("label".to_owned(), AttrValue::Str(label.to_owned()));
66 let Some(span) = label_span else {
67 return;
68 };
69 let (Ok(start), Ok(end)) = (i64::try_from(span.start()), i64::try_from(span.end())) else {
70 return;
74 };
75 attributes.insert(LABEL_SPAN_START_ATTR.to_owned(), AttrValue::Int(start));
76 attributes.insert(LABEL_SPAN_END_ATTR.to_owned(), AttrValue::Int(end));
77}
78
79pub(crate) fn string_content_span(value_span: &SourceSpan) -> SourceSpan {
81 if value_span.end() > value_span.start().saturating_add(1) {
82 SourceSpan::new(
83 value_span.file.clone(),
84 value_span.start() + 1,
85 value_span.end() - 1,
86 )
87 } else {
88 value_span.clone()
89 }
90}
91
92#[derive(Debug, Clone, Default, PartialEq, Eq)]
112pub struct DocumentMetadata {
113 pub title: Option<String>,
114 pub author: Option<String>,
115 pub language: Option<String>,
116}
117
118#[derive(Debug)]
141pub struct LowerResult {
142 pub document: Document,
145 pub diagnostics: Vec<Diagnostic>,
146 pub metadata: DocumentMetadata,
147 pub external_dependencies: Vec<ExternalDependency>,
155 pub resources: ResourceSnapshot,
157 pub bibliography: Bibliography,
161 pub bibliography_complete: bool,
164 pub citation_spans: Vec<std::ops::Range<usize>>,
167}
168
169impl LowerResult {
170 #[must_use]
182 pub fn reads_external_resources(&self) -> bool {
183 !self.external_dependencies.is_empty()
184 }
185
186 #[must_use]
198 pub fn has_errors(&self) -> bool {
199 self.diagnostics
200 .iter()
201 .any(|d| d.severity() == Severity::Error)
202 }
203}
204
205#[derive(Copy, Clone, Default, Debug)]
217pub struct Evaluator;
218
219impl Evaluator {
220 #[must_use]
232 pub const fn new() -> Self {
233 Self
234 }
235
236 #[must_use]
261 pub fn evaluate(tree: &SyntaxTree) -> LowerResult {
262 Self::evaluate_with_reader(tree, &FileSystemReader)
263 }
264
265 #[must_use]
268 pub fn evaluate_with_reader(tree: &SyntaxTree, reader: &dyn ResourceReader) -> LowerResult {
269 let mut lowered = Self::evaluate_unhashed(tree, reader);
270 semantic_hash::stamp(&mut lowered.document, &lowered.external_dependencies, false);
271 lowered
272 }
273
274 fn evaluate_unhashed(tree: &SyntaxTree, reader: &dyn ResourceReader) -> LowerResult {
275 let mut state = EvaluationState::new(tree, reader);
276 for item in &tree.items {
277 state.lower_item(item, &tree.file);
278 }
279 state.finish(tree)
280 }
281}
282
283struct EvaluationState<'a> {
284 document: Document,
285 diagnostics: Vec<Diagnostic>,
286 metadata: DocumentMetadata,
287 current_text_size_pt: f64,
288 resources: ResourceSnapshot,
289 reader: &'a dyn ResourceReader,
290 pending_doc: Option<String>,
295}
296
297impl<'a> EvaluationState<'a> {
298 fn new(tree: &SyntaxTree, reader: &'a dyn ResourceReader) -> Self {
299 Self {
300 document: Document::new(tree.file.clone()),
301 diagnostics: Vec::new(),
302 metadata: DocumentMetadata::default(),
303 current_text_size_pt: 11.0,
304 resources: ResourceSnapshot::default(),
305 reader,
306 pending_doc: None,
307 }
308 }
309
310 fn finish(self, tree: &SyntaxTree) -> LowerResult {
311 LowerResult {
312 document: self.document,
313 diagnostics: self.diagnostics,
314 metadata: self.metadata,
315 external_dependencies: self.resources.dependencies(),
316 resources: self.resources,
317 bibliography: Bibliography::default(),
318 bibliography_complete: false,
319 citation_spans: tree.citation_spans.clone(),
320 }
321 }
322
323 fn lower_item(&mut self, item: &Item, source_file: &std::path::Path) {
324 match item {
328 Item::DocComment { text, .. } => {
329 self.pending_doc = Some(text.clone());
330 }
331 Item::Heading {
332 level,
333 inlines,
334 label,
335 label_span,
336 span,
337 } => {
338 let doc = self.pending_doc.take();
339 self.lower_heading(
340 *level,
341 inlines,
342 label.as_deref(),
343 label_span.as_ref(),
344 span,
345 doc,
346 );
347 }
348 Item::Paragraph {
349 inlines,
350 label,
351 label_span,
352 span,
353 } => {
354 let doc = self.pending_doc.take();
355 self.lower_paragraph(inlines, label.as_deref(), label_span.as_ref(), span, doc);
356 }
357 Item::List {
358 ordered,
359 items,
360 span,
361 } => {
362 self.pending_doc = None;
363 let root = self.document.root;
364 lower_list(&mut self.document, root, *ordered, items, span);
365 }
366 Item::RawBlock { .. } => {
367 self.pending_doc = None;
368 let root = self.document.root;
369 if let Some(raw) = item.as_raw_block() {
370 lower_raw_block(&mut self.document, root, raw);
371 }
372 }
373 Item::Set {
374 kind,
375 name,
376 args,
377 span,
378 } => {
379 self.pending_doc = None;
380 self.lower_directive(*kind, name, args, span, source_file);
381 }
382 }
383 }
384
385 fn lower_heading(
386 &mut self,
387 level: u8,
388 inlines: &[mos_parse::Inline],
389 label: Option<&str>,
390 label_span: Option<&SourceSpan>,
391 span: &SourceSpan,
392 doc: Option<String>,
393 ) {
394 let mut attributes: AttrMap = BTreeMap::new();
395 attributes.insert("level".to_owned(), AttrValue::Int(i64::from(level)));
396 if let Some(id) = label {
397 insert_label_attributes(&mut attributes, id, label_span);
398 }
399 if let Some(doc) = doc {
400 attributes.insert(DOC_ATTR.to_owned(), AttrValue::Str(doc));
401 }
402 let heading = self.document.alloc_child(
403 self.document.root,
404 NodeSpec::new(NodeKind::Section, span.clone()).with_attributes(attributes),
405 );
406 lower_inlines(&mut self.document, heading, inlines);
407 }
408
409 fn lower_paragraph(
410 &mut self,
411 inlines: &[mos_parse::Inline],
412 label: Option<&str>,
413 label_span: Option<&SourceSpan>,
414 span: &SourceSpan,
415 doc: Option<String>,
416 ) {
417 let mut attributes: AttrMap = BTreeMap::new();
418 if let Some(id) = label {
419 insert_label_attributes(&mut attributes, id, label_span);
420 }
421 if let Some(doc) = doc {
422 attributes.insert(DOC_ATTR.to_owned(), AttrValue::Str(doc));
423 }
424 let para = self.document.alloc_child(
425 self.document.root,
426 NodeSpec::new(NodeKind::Paragraph, span.clone()).with_attributes(attributes),
427 );
428 lower_inlines(&mut self.document, para, inlines);
429 }
430
431 fn lower_directive(
432 &mut self,
433 kind: DirectiveKind,
434 name: &str,
435 args: &[SetArg],
436 span: &SourceSpan,
437 source_file: &std::path::Path,
438 ) {
439 let mut inputs = ExternalInputs {
440 source_file,
441 resources: &mut self.resources,
442 reader: self.reader,
443 };
444 match kind {
445 DirectiveKind::Image => {
446 let root = self.document.root;
447 lower_image_directive(
448 &mut self.document,
449 root,
450 args,
451 span,
452 &mut inputs,
453 self.current_text_size_pt,
454 &mut self.diagnostics,
455 );
456 }
457 DirectiveKind::Figure => {
458 let root = self.document.root;
459 lower_figure_directive(
460 &mut self.document,
461 root,
462 args,
463 span,
464 &mut inputs,
465 self.current_text_size_pt,
466 &mut self.diagnostics,
467 );
468 }
469 DirectiveKind::Bibliography => {
470 let root = self.document.root;
471 lower_bibliography_directive(
472 &mut self.document,
473 root,
474 args,
475 span,
476 &mut inputs,
477 &mut self.diagnostics,
478 );
479 }
480 DirectiveKind::Set => {
481 let root = self.document.root;
482 lower_set_directive(
483 &mut self.document,
484 root,
485 name,
486 args,
487 span,
488 &mut self.metadata,
489 &mut self.current_text_size_pt,
490 &mut self.diagnostics,
491 );
492 }
493 }
494 }
495}
496
497fn lower_raw_block(document: &mut Document, root: NodeId, raw: RawBlockView<'_>) {
498 let mut attributes: AttrMap = BTreeMap::new();
499 attributes.insert("text".to_owned(), AttrValue::Str(raw.text.to_owned()));
500 if let Some(id) = raw.label {
501 insert_label_attributes(&mut attributes, id, raw.label_span);
502 }
503 if raw.kind == RawBlockKind::Code
507 && let Some(SetArg::Named {
508 value: SetValue::Str(language) | SetValue::Ident(language),
509 ..
510 }) = raw
511 .args
512 .iter()
513 .rev()
514 .find(|arg| matches!(arg, SetArg::Named { key, .. } if key == "lang"))
515 {
516 attributes.insert(
517 CODE_LANGUAGE_ATTR.to_owned(),
518 AttrValue::Str(language.clone()),
519 );
520 }
521 attributes.insert(
522 "raw.kind".to_owned(),
523 AttrValue::Str(
524 match raw.kind {
525 RawBlockKind::Pre => "pre",
526 RawBlockKind::Code => "code",
527 }
528 .to_owned(),
529 ),
530 );
531 document.alloc_child(
532 root,
533 NodeSpec::new(NodeKind::Raw, raw.span.clone()).with_attributes(attributes),
534 );
535}
536
537#[must_use]
551pub fn lower(src: &str, file: &std::path::Path) -> LowerResult {
552 lower_with_reader(src, file, &FileSystemReader)
553}
554
555#[must_use]
584pub fn lower_with_reader(
585 src: &str,
586 file: &std::path::Path,
587 reader: &dyn ResourceReader,
588) -> LowerResult {
589 let mut sink = CollectingSink::new();
590 let tree = match mos_parse::parse(src, file, &mut sink) {
591 Ok(tree) => tree,
592 Err(mos_core::DiagnosticAbort) => {
595 return LowerResult {
596 document: Document::new(file.to_path_buf()),
597 diagnostics: sink.into_diagnostics(),
598 metadata: DocumentMetadata::default(),
599 external_dependencies: Vec::new(),
600 resources: ResourceSnapshot::default(),
601 bibliography: Bibliography::default(),
602 bibliography_complete: false,
603 citation_spans: Vec::new(),
604 };
605 }
606 };
607 let mut diagnostics = sink.into_diagnostics();
608 let mut lowered = lower_tree_with_reader(&tree, reader);
609 diagnostics.append(&mut lowered.diagnostics);
610 LowerResult {
611 document: lowered.document,
612 diagnostics,
613 metadata: lowered.metadata,
614 external_dependencies: lowered.external_dependencies,
615 resources: lowered.resources,
616 bibliography: lowered.bibliography,
617 bibliography_complete: lowered.bibliography_complete,
618 citation_spans: lowered.citation_spans,
619 }
620}
621
622#[must_use]
646pub fn lower_tree(tree: &SyntaxTree) -> LowerResult {
647 lower_tree_with_reader(tree, &FileSystemReader)
648}
649
650#[must_use]
652pub fn lower_tree_with_reader(tree: &SyntaxTree, reader: &dyn ResourceReader) -> LowerResult {
653 let mut lowered = Evaluator::evaluate_unhashed(tree, reader);
654 let mut diagnostics = std::mem::take(&mut lowered.diagnostics);
655 let loaded_bibliography = load_bibliography(
656 &lowered.document,
657 &mut diagnostics,
658 reader,
659 &mut lowered.resources,
660 );
661 let external_dependencies = lowered.resources.dependencies();
662 semantic_hash::stamp(&mut lowered.document, &external_dependencies, true);
665 let (bibliography, bibliography_complete) =
666 resolve_citations(&mut lowered.document, &mut diagnostics, loaded_bibliography);
667 let bib_keys: BTreeSet<String> = bibliography.entries.keys().cloned().collect();
668 diagnostics.extend(resolve(&mut lowered.document, &bib_keys));
669 LowerResult {
670 document: lowered.document,
671 diagnostics,
672 metadata: lowered.metadata,
673 external_dependencies,
674 resources: lowered.resources,
675 bibliography,
676 bibliography_complete,
677 citation_spans: lowered.citation_spans,
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 #![allow(
684 clippy::unwrap_used,
685 clippy::expect_used,
686 clippy::panic,
687 reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
688 )]
689 use std::path::PathBuf;
690
691 use mos_core::{NodeKind, codes};
692
693 use super::*;
694
695 #[test]
696 fn supplied_resources_decode_images_resolve_citations_and_capture_once() {
697 use std::{cell::RefCell, io::Cursor, path::Path};
698 let mut png = Cursor::new(Vec::new());
699 ::image::RgbaImage::from_pixel(1, 1, ::image::Rgba([10, 20, 30, 255]))
700 .write_to(&mut png, ::image::ImageFormat::Png)
701 .unwrap();
702 let reads = RefCell::new(Vec::new());
703 let reader = |path: &Path| {
704 reads.borrow_mut().push(path.to_path_buf());
705 if path == Path::new("virtual/pixel.png") {
706 Ok(ResourceData::new(png.get_ref().clone()))
707 } else if path == Path::new("virtual/refs.bib") {
708 Ok(ResourceData::new(b"@book{k, title={Memory}}".as_slice()))
709 } else {
710 Err(std::io::Error::from(std::io::ErrorKind::NotFound))
711 }
712 };
713 let result = lower_with_reader(
714 "#image(\"pixel.png\")\n#figure(image: \"./pixel.png\")\n#bibliography(\"refs.bib\")\nSee [@k]\n",
715 Path::new("virtual/main.mos"),
716 &reader,
717 );
718 assert!(!result.has_errors(), "{:?}", result.diagnostics);
719 assert!(result.bibliography_complete);
720 assert_eq!(result.bibliography.entries["k"].fields["title"], "{Memory}");
721 assert_eq!(reads.borrow().len(), 2);
722 assert_eq!(result.external_dependencies.len(), 2);
723 for dependency in &result.external_dependencies {
724 let bytes = result.resources.get(&dependency.path).unwrap().unwrap();
725 assert_eq!(
726 dependency.fingerprint,
727 Some(ResourceFingerprint::Content(fingerprint_bytes(bytes)))
728 );
729 }
730 let images: Vec<_> = result
731 .document
732 .nodes()
733 .filter(|node| node.kind == NodeKind::Image)
734 .collect();
735 assert_eq!(images.len(), 2);
736 assert_eq!(images[0].attributes["pixel_width"], AttrValue::Int(1));
737 }
738
739 #[test]
740 fn failed_reads_are_captured_once_and_suppress_missing_citations() {
741 use std::{cell::Cell, io, path::Path};
742 for kind in [io::ErrorKind::NotFound, io::ErrorKind::PermissionDenied] {
743 let calls = Cell::new(0);
744 let reader = |_: &Path| {
745 calls.set(calls.get() + 1);
746 if calls.get() == 1 {
747 Err(io::Error::new(kind, "unavailable"))
748 } else {
749 Ok(ResourceData::new(b"@book{k, title={Later}}".as_slice()))
750 }
751 };
752 let result = lower_with_reader(
753 "#image(\"missing.bib\")\n#bibliography(\"missing.bib\")\nSee [@k]\n",
754 Path::new("virtual/main.mos"),
755 &reader,
756 );
757 assert_eq!(calls.get(), 1);
758 assert!(!result.bibliography_complete);
759 assert!(result.bibliography.entries.is_empty());
760 assert_eq!(result.external_dependencies.len(), 1);
761 assert_eq!(result.external_dependencies[0].fingerprint, None);
762 assert_eq!(
763 result
764 .resources
765 .get(Path::new("virtual/missing.bib"))
766 .unwrap()
767 .unwrap_err()
768 .kind(),
769 kind
770 );
771 assert!(
772 !result
773 .diagnostics
774 .iter()
775 .any(|d| d.def().code() == codes::MOS0045.code())
776 );
777 }
778 }
779
780 #[test]
781 fn resource_versions_change_authored_hashes_without_mutating_old_snapshots() {
782 use std::{cell::Cell, path::Path};
783 let version = Cell::new(false);
784 let reader = |_: &Path| {
785 Ok(ResourceData::new(if version.get() {
786 b"@book{k, title={New}}".as_slice()
787 } else {
788 b"@book{k, title={Old}}".as_slice()
789 }))
790 };
791 let source = "#bibliography(\"refs.bib\")\nSee [@k]\n";
792 let old = lower_with_reader(source, Path::new("virtual/main.mos"), &reader);
793 version.set(true);
794 let new = lower_with_reader(source, Path::new("virtual/main.mos"), &reader);
795 let hash = |result: &LowerResult| {
796 result
797 .document
798 .nodes()
799 .find(|node| node.kind == NodeKind::Bibliography)
800 .unwrap()
801 .content_hash()
802 };
803 assert_ne!(hash(&old), hash(&new));
804 assert_eq!(
805 old.resources
806 .get(Path::new("virtual/refs.bib"))
807 .unwrap()
808 .unwrap(),
809 b"@book{k, title={Old}}"
810 );
811 }
812
813 #[test]
814 fn supplied_resources_match_filesystem_lowering() {
815 use std::path::Path;
816 let image = write_tiny_png("reader-equivalence.png");
817 let dir = image.parent().unwrap();
818 std::fs::write(dir.join("refs.bib"), "@book{k, title={Same}}\n").unwrap();
819 let source = "#image(\"reader-equivalence.png\")\n#bibliography(\"refs.bib\")\nSee [@k]\n";
820 let file = dir.join("main.mos");
821 let disk = lower(source, &file);
822 let reader = |path: &Path| {
823 Ok(ResourceData::new(
824 disk.resources.get(path).unwrap().unwrap(),
825 ))
826 };
827 let supplied = lower_with_reader(source, &file, &reader);
828 assert!(!disk.has_errors());
829 assert!(!supplied.has_errors());
830 assert_eq!(disk.diagnostics.len(), supplied.diagnostics.len());
831 assert_eq!(disk.document.len(), supplied.document.len());
832 for (disk_node, supplied_node) in disk.document.nodes().zip(supplied.document.nodes()) {
833 assert_eq!(disk_node.kind, supplied_node.kind);
834 assert_eq!(disk_node.attributes, supplied_node.attributes);
835 assert_eq!(disk_node.content_hash(), supplied_node.content_hash());
836 }
837 for (disk_dep, supplied_dep) in disk
838 .external_dependencies
839 .iter()
840 .zip(&supplied.external_dependencies)
841 {
842 assert_eq!(disk_dep.path, supplied_dep.path);
843 assert_eq!(
844 disk_dep.fingerprint.map(ResourceFingerprint::content),
845 supplied_dep.fingerprint.map(ResourceFingerprint::content)
846 );
847 }
848 std::fs::remove_dir_all(dir).unwrap();
849 }
850
851 #[test]
852 fn invalid_resource_bytes_keep_existing_diagnostics() {
853 use std::path::Path;
854 let reader = |_: &Path| Ok(ResourceData::new(vec![255]));
855 let result = lower_with_reader(
856 "#image(\"bad.png\")\n#bibliography(\"bad.bib\")\nSee [@k]\n",
857 Path::new("virtual/main.mos"),
858 &reader,
859 );
860 assert!(
861 result
862 .diagnostics
863 .iter()
864 .any(|d| d.def().code() == codes::MOS0029.code())
865 );
866 assert!(
867 result
868 .diagnostics
869 .iter()
870 .any(|d| d.def().code() == codes::MOS0041.code())
871 );
872 assert!(!result.bibliography_complete);
873 assert_eq!(result.external_dependencies.len(), 2);
874 }
875
876 #[test]
877 fn code_language_is_authored_metadata_with_last_argument_winning() {
878 let src = "#code(lang: \"rust\", lang: python)[[print(1)]] <sample>\n";
879 let result = lower(src, &PathBuf::from("main.mos"));
880 assert!(!result.has_errors());
881 let node = result
882 .document
883 .nodes()
884 .find(|node| node.kind == NodeKind::Raw)
885 .unwrap();
886 assert_eq!(
887 node.attributes[CODE_LANGUAGE_ATTR],
888 AttrValue::Str("python".into())
889 );
890 assert_eq!(node.attributes["text"], AttrValue::Str("print(1)".into()));
891 assert_eq!(node.attributes["label"], AttrValue::Str("sample".into()));
892 let changed = lower(
893 &src.replace("lang: python", "lang: rust"),
894 &PathBuf::from("main.mos"),
895 );
896 let changed = changed
897 .document
898 .nodes()
899 .find(|node| node.kind == NodeKind::Raw)
900 .unwrap();
901 assert_ne!(changed.content_hash(), node.content_hash());
902 }
903
904 #[test]
905 fn absent_or_nontext_code_language_and_pre_options_stay_uninterpreted() {
906 for src in [
907 "#code[[body]]",
908 "#code(lang: \"rust\", lang: 42)[[body]]",
909 "#pre(lang: \"rust\")[[body]]",
910 ] {
911 let result = lower(src, &PathBuf::from("main.mos"));
912 assert!(!result.has_errors(), "{:?}", result.diagnostics);
913 let node = result
914 .document
915 .nodes()
916 .find(|node| node.kind == NodeKind::Raw)
917 .unwrap();
918 assert!(!node.attributes.contains_key(CODE_LANGUAGE_ATTR));
919 assert_eq!(node.attributes["text"], AttrValue::Str("body".into()));
920 }
921 }
922
923 #[cfg(target_pointer_width = "64")]
924 #[test]
925 fn label_attributes_omit_unrepresentable_span_bounds() {
926 let too_large = usize::try_from(i64::MAX).unwrap().saturating_add(1);
927 let span = SourceSpan::new(
928 PathBuf::from("test.mos"),
929 too_large,
930 too_large.saturating_add(1),
931 );
932 let mut attributes = AttrMap::new();
933
934 insert_label_attributes(&mut attributes, "huge", Some(&span));
935
936 assert_eq!(
937 attributes.get("label"),
938 Some(&AttrValue::Str("huge".to_owned()))
939 );
940 assert!(!attributes.contains_key(LABEL_SPAN_START_ATTR));
941 assert!(!attributes.contains_key(LABEL_SPAN_END_ATTR));
942 }
943
944 #[test]
945 fn doc_comment_attaches_to_following_heading() {
946 let file = PathBuf::from("test.mos");
947 let r = lower("/** Section doc. */\n= Intro <intro>\n", &file);
948 let section = r
949 .document
950 .nodes()
951 .find(|n| n.kind == NodeKind::Section)
952 .expect("a section node");
953 assert_eq!(
954 section.attributes.get(DOC_ATTR),
955 Some(&AttrValue::Str("Section doc.".to_owned())),
956 );
957 }
958
959 #[test]
960 fn doc_comment_attaches_to_following_paragraph() {
961 let file = PathBuf::from("test.mos");
962 let r = lower("/** Para doc. */\nBody text.\n", &file);
963 let para = r
964 .document
965 .nodes()
966 .find(|n| n.kind == NodeKind::Paragraph)
967 .expect("a paragraph node");
968 assert_eq!(
969 para.attributes.get(DOC_ATTR),
970 Some(&AttrValue::Str("Para doc.".to_owned())),
971 );
972 }
973
974 #[test]
975 fn doc_comment_does_not_leak_past_a_non_documentable_block() {
976 let file = PathBuf::from("test.mos");
979 let r = lower("/** stray */\n#set document(title: \"x\")\n\n= H\n", &file);
980 let section = r
981 .document
982 .nodes()
983 .find(|n| n.kind == NodeKind::Section)
984 .expect("a section node");
985 assert!(
986 !section.attributes.contains_key(DOC_ATTR),
987 "doc must not leak past #set"
988 );
989 }
990
991 #[test]
992 fn doc_comment_does_not_attach_to_or_leak_past_a_list() {
993 let file = PathBuf::from("test.mos");
994 let r = lower("/** stray */\n- item\n\n= H\n", &file);
995 let list = r
996 .document
997 .nodes()
998 .find(|n| n.kind == NodeKind::List)
999 .expect("a list node");
1000 assert!(
1001 !list.attributes.contains_key(DOC_ATTR),
1002 "doc must not attach to a list"
1003 );
1004 let section = r
1005 .document
1006 .nodes()
1007 .find(|n| n.kind == NodeKind::Section)
1008 .expect("a section node");
1009 assert!(
1010 !section.attributes.contains_key(DOC_ATTR),
1011 "doc must not leak past a list"
1012 );
1013 }
1014
1015 #[test]
1016 fn doc_comment_does_not_attach_to_or_leak_past_a_raw_block() {
1017 let file = PathBuf::from("test.mos");
1018 let r = lower("/** stray */\n#code[[x]]\n\n= H\n", &file);
1019 let raw = r
1020 .document
1021 .nodes()
1022 .find(|n| n.kind == NodeKind::Raw)
1023 .expect("a raw node");
1024 assert!(
1025 !raw.attributes.contains_key(DOC_ATTR),
1026 "doc must not attach to a raw block"
1027 );
1028 let section = r
1029 .document
1030 .nodes()
1031 .find(|n| n.kind == NodeKind::Section)
1032 .expect("a section node");
1033 assert!(
1034 !section.attributes.contains_key(DOC_ATTR),
1035 "doc must not leak past a raw block"
1036 );
1037 }
1038
1039 #[test]
1040 fn plain_block_comment_attaches_no_doc() {
1041 let file = PathBuf::from("test.mos");
1042 let r = lower("/* not doc */\n= H\n", &file);
1043 let section = r
1044 .document
1045 .nodes()
1046 .find(|n| n.kind == NodeKind::Section)
1047 .expect("a section node");
1048 assert!(!section.attributes.contains_key(DOC_ATTR));
1049 }
1050
1051 #[test]
1052 fn lowering_records_external_dependencies_with_fingerprints() {
1053 let dir = unique_temp_dir("deps");
1054 let source = dir.join("main.mos");
1055 let image = dir.join("x.png");
1056 let bib = dir.join("refs.bib");
1057 std::fs::write(&image, b"not a png").unwrap();
1058 std::fs::write(&bib, "@book{k, title={T}}\n").unwrap();
1059 let r = lower(
1060 "#image(\"x.png\")\n#image(\"x.png\")\n#bibliography(\"refs.bib\")\n#image(\"missing.png\")\n",
1061 &source,
1062 );
1063
1064 let deps = &r.external_dependencies;
1065 assert_eq!(
1066 deps.iter().map(|d| d.path.clone()).collect::<Vec<_>>(),
1067 vec![dir.join("missing.png"), bib, image.clone()],
1068 "one entry per distinct file, sorted by path: {deps:?}"
1069 );
1070 let content = |dep: &ExternalDependency| dep.fingerprint.map(ResourceFingerprint::content);
1071 assert_eq!(content(&deps[0]), None, "a missing file records no hash");
1072 assert_eq!(
1073 content(&deps[1]),
1074 Some(fingerprint_bytes(b"@book{k, title={T}}\n"))
1075 );
1076 assert_eq!(content(&deps[2]), Some(fingerprint_bytes(b"not a png")));
1077 assert!(deps.iter().all(ExternalDependency::is_current));
1078
1079 std::fs::write(&image, b"changed").unwrap();
1080 assert!(
1081 !deps[2].is_current(),
1082 "a rewritten file is no longer current"
1083 );
1084 assert!(deps[1].is_current());
1085 std::fs::remove_dir_all(&dir).ok();
1086 }
1087
1088 #[cfg(unix)]
1089 #[test]
1090 fn non_utf8_bibliography_path_warns_and_records_the_real_file() {
1091 use std::ffi::OsStr;
1092 use std::os::unix::ffi::OsStrExt;
1093
1094 let dir = unique_temp_dir("non-utf8").join(OsStr::from_bytes(b"caf\xe9"));
1095 std::fs::create_dir_all(&dir).unwrap();
1096 let bib = dir.join("refs.bib");
1097 std::fs::write(&bib, "@book{k, title={T}}\n").unwrap();
1098 let source = dir.join("main.mos");
1099 let r = lower("#bibliography(\"refs.bib\")\n\nsee [@k]\n", &source);
1100
1101 let warning = r
1102 .diagnostics
1103 .iter()
1104 .find(|d| d.def().code() == codes::MOS0041.code())
1105 .expect("MOS0041 for the unloadable source");
1106 assert!(
1107 warning.message().contains("non-UTF-8"),
1108 "message: {}",
1109 warning.message()
1110 );
1111 let node = r
1112 .document
1113 .nodes()
1114 .find(|n| n.kind == NodeKind::Bibliography)
1115 .expect("the source is still declared");
1116 assert_eq!(
1117 node.attributes.get("src"),
1118 Some(&AttrValue::Str("refs.bib".to_owned()))
1119 );
1120 assert!(
1121 !node.attributes.contains_key("resolved_path"),
1122 "a path the model cannot name is left unresolved"
1123 );
1124 assert!(
1125 !r.diagnostics
1126 .iter()
1127 .any(|d| d.def().code() == codes::MOS0045.code()),
1128 "an unloadable source makes the record set incomplete, so no key is reported missing"
1129 );
1130 assert_eq!(r.external_dependencies.len(), 1);
1131 assert_eq!(r.external_dependencies[0].path, bib);
1132 assert!(r.external_dependencies[0].fingerprint.is_some());
1133 assert!(r.external_dependencies[0].is_current());
1134 std::fs::remove_dir_all(dir.parent().unwrap()).ok();
1135 }
1136
1137 #[test]
1138 fn reads_external_resources_flags_filesystem_directives() {
1139 let file = PathBuf::from("test.mos");
1140 assert!(
1142 !lower("= Title <t>\n\nSee @t\n", &file).reads_external_resources(),
1143 "a source with no filesystem directives lowers purely"
1144 );
1145 assert!(
1149 lower("#image(\"missing.png\")\n", &file).reads_external_resources(),
1150 "`#image` reads an external file"
1151 );
1152 assert!(
1153 lower("#figure(image: \"missing.png\")\n", &file).reads_external_resources(),
1154 "`#figure` loads an external image"
1155 );
1156 assert!(
1157 lower("#bibliography(\"missing.bib\")\n", &file).reads_external_resources(),
1158 "`#bibliography` reads an external source file"
1159 );
1160 }
1161
1162 #[test]
1163 fn lowerer_stamps_paired_label_span_covering_the_label_token() {
1164 let src = "= Intro <intro>\n";
1172 let r = lower(src, &PathBuf::from("test.mos"));
1173 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1174
1175 let section = r
1176 .document
1177 .nodes()
1178 .find(|node| {
1179 node.kind == NodeKind::Section
1180 && node.attributes.get("label") == Some(&AttrValue::Str("intro".to_owned()))
1181 })
1182 .expect("a Section node carrying label `intro`");
1183
1184 let (Some(&AttrValue::Int(start)), Some(&AttrValue::Int(end))) = (
1185 section.attributes.get(LABEL_SPAN_START_ATTR),
1186 section.attributes.get(LABEL_SPAN_END_ATTR),
1187 ) else {
1188 panic!(
1189 "expected paired Int `label_span.*` attrs, got {:?} / {:?}",
1190 section.attributes.get(LABEL_SPAN_START_ATTR),
1191 section.attributes.get(LABEL_SPAN_END_ATTR),
1192 );
1193 };
1194
1195 let start = usize::try_from(start).expect("label_span.start fits usize");
1196 let end = usize::try_from(end).expect("label_span.end fits usize");
1197 assert!(
1198 start <= end,
1199 "label span start {start} must not exceed end {end}"
1200 );
1201 assert_eq!(
1202 src.get(start..end),
1203 Some("intro"),
1204 "label span must cover exactly the `intro` token, not the whole heading"
1205 );
1206 }
1207
1208 #[test]
1209 fn reference_nodes_carry_stamped_label_span() {
1210 let src = "= Intro <intro>\n\nsee @intro and @page(intro)\n";
1215 let r = lower(src, &PathBuf::from("test.mos"));
1216 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1217
1218 for kind in [NodeKind::Reference, NodeKind::PageReference] {
1219 let node = r
1220 .document
1221 .nodes()
1222 .find(|node| node.kind == kind)
1223 .unwrap_or_else(|| panic!("expected a {kind:?} node"));
1224 let (Some(&AttrValue::Int(start)), Some(&AttrValue::Int(end))) = (
1225 node.attributes.get(LABEL_SPAN_START_ATTR),
1226 node.attributes.get(LABEL_SPAN_END_ATTR),
1227 ) else {
1228 panic!(
1229 "{kind:?} node missing paired `label_span.*`: {:?}",
1230 node.attributes
1231 );
1232 };
1233 let start = usize::try_from(start).expect("label_span.start fits usize");
1234 let end = usize::try_from(end).expect("label_span.end fits usize");
1235 assert_eq!(
1236 src.get(start..end),
1237 Some("intro"),
1238 "{kind:?} label span must cover exactly the `intro` identifier"
1239 );
1240 }
1241 }
1242
1243 #[test]
1244 fn lowers_heading_and_paragraph() {
1245 let r = lower(
1246 "= Hello\n\nbody *italic* text\n",
1247 &PathBuf::from("test.mos"),
1248 );
1249 assert!(!r.has_errors());
1250 assert_eq!(r.document.len(), 1 + 2 + 1 + 3);
1253
1254 let kinds: Vec<NodeKind> = r.document.nodes().map(|n| n.kind).collect();
1255 assert_eq!(kinds[0], NodeKind::Document);
1256 assert!(kinds.contains(&NodeKind::Section));
1257 assert!(kinds.contains(&NodeKind::Paragraph));
1258 assert!(kinds.contains(&NodeKind::Emphasis));
1259 }
1260
1261 #[test]
1262 fn lowers_nested_bold_italic_inline() {
1263 let r = lower("***both***\n", &PathBuf::from("test.mos"));
1264 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1265 assert!(
1266 r.document.nodes().any(|n| n.kind == NodeKind::BoldItalic),
1267 "expected bold-italic node in {:?}",
1268 r.document.nodes().map(|n| n.kind).collect::<Vec<_>>()
1269 );
1270 }
1271
1272 #[test]
1273 fn root_owns_top_level_items() {
1274 let r = lower("= A\n\n= B\n\npara\n", &PathBuf::from("test.mos"));
1275 let root = r.document.get(r.document.root).unwrap();
1276 assert_eq!(root.children.len(), 3);
1277 }
1278
1279 #[test]
1280 fn hard_break_lowers_to_hardbreak_node_without_text_attr() {
1281 let r = lower("foo\\\\bar\n", &PathBuf::from("test.mos"));
1282 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1283
1284 let root = r.document.get(r.document.root).unwrap();
1287 let paragraph_id = *root.children.first().unwrap();
1288 let paragraph = r.document.get(paragraph_id).unwrap();
1289 let inline_kinds: Vec<NodeKind> = paragraph
1290 .children
1291 .iter()
1292 .filter_map(|id| r.document.get(*id).map(|n| n.kind))
1293 .collect();
1294 assert_eq!(
1295 inline_kinds,
1296 vec![NodeKind::Text, NodeKind::HardBreak, NodeKind::Text],
1297 "got {inline_kinds:?}"
1298 );
1299
1300 let hardbreak_id = paragraph.children[1];
1303 let hardbreak = r.document.get(hardbreak_id).unwrap();
1304 assert!(
1305 hardbreak.attributes.is_empty(),
1306 "expected empty attribute map on HardBreak, got {:?}",
1307 hardbreak.attributes
1308 );
1309 }
1310
1311 fn write_tiny_png(name: &str) -> PathBuf {
1317 let dir = std::env::temp_dir().join(format!(
1318 "mos-eval-image-{}-{}",
1319 name,
1320 std::time::SystemTime::now()
1321 .duration_since(std::time::UNIX_EPOCH)
1322 .map_or(0, |d| d.as_nanos())
1323 ));
1324 std::fs::create_dir_all(&dir).unwrap();
1325 let path = dir.join(name);
1326 let mut buf = ::image::RgbaImage::new(3, 2);
1327 for x in 0_u32..3 {
1328 for y in 0_u32..2 {
1329 let r = u8::try_from(x * 80).unwrap_or(0);
1330 let g = u8::try_from(y * 120).unwrap_or(0);
1331 buf.put_pixel(x, y, ::image::Rgba([r, g, 200, 255]));
1332 }
1333 }
1334 buf.save(&path).unwrap();
1335 path
1336 }
1337
1338 #[test]
1339 fn image_directive_attaches_decoded_pixels() {
1340 let png_path = write_tiny_png("tiny.png");
1341 let source = png_path.parent().unwrap().join("main.mos");
1342 std::fs::write(&source, "#image(\"tiny.png\")\n").unwrap();
1343 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1344 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1345 let image_node = r
1346 .document
1347 .nodes()
1348 .find(|n| n.kind == NodeKind::Image)
1349 .expect("Image node");
1350 assert_eq!(
1351 image_node.attributes.get("src"),
1352 Some(&AttrValue::Str("tiny.png".to_owned()))
1353 );
1354 assert_eq!(
1355 image_node.attributes.get("pixel_width"),
1356 Some(&AttrValue::Int(3))
1357 );
1358 assert_eq!(
1359 image_node.attributes.get("pixel_height"),
1360 Some(&AttrValue::Int(2))
1361 );
1362 match image_node.attributes.get("pixels") {
1363 Some(AttrValue::Bytes(b)) => assert_eq!(b.len(), 3 * 3 * 2),
1364 other => panic!("expected pixel bytes, got {other:?}"),
1365 }
1366 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1367 }
1368
1369 #[test]
1370 fn image_directive_records_explicit_dimensions() {
1371 let png_path = write_tiny_png("dims.png");
1372 let source = png_path.parent().unwrap().join("main.mos");
1373 std::fs::write(
1374 &source,
1375 "#image(\"dims.png\", width: 100pt, height: 60pt, alt: \"a tiny image\")\n",
1376 )
1377 .unwrap();
1378 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1379 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1380 let image_node = r
1381 .document
1382 .nodes()
1383 .find(|n| n.kind == NodeKind::Image)
1384 .expect("Image node");
1385 assert_eq!(
1386 image_node.attributes.get("width"),
1387 Some(&AttrValue::Length(100.0))
1388 );
1389 assert_eq!(
1390 image_node.attributes.get("height"),
1391 Some(&AttrValue::Length(60.0))
1392 );
1393 assert_eq!(
1394 image_node.attributes.get("alt"),
1395 Some(&AttrValue::Str("a tiny image".to_owned()))
1396 );
1397 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1398 }
1399
1400 #[test]
1401 fn image_em_width_resolves_against_current_text_size() {
1402 let png_path = write_tiny_png("em.png");
1407 let dir = png_path.parent().unwrap();
1408 let source = dir.join("main.mos");
1409 std::fs::write(
1410 &source,
1411 "#set text(size: 20pt)\n#image(\"em.png\", width: 2em)\n",
1412 )
1413 .unwrap();
1414 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1415 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1416 let image_node = r
1417 .document
1418 .nodes()
1419 .find(|n| n.kind == NodeKind::Image)
1420 .expect("Image node");
1421 match image_node.attributes.get("width") {
1422 Some(AttrValue::Length(pt)) => assert!(
1423 (pt - 40.0).abs() < 0.01,
1424 "width = {pt}pt, expected 40pt (2em at 20pt)"
1425 ),
1426 other => panic!("expected width Length, got {other:?}"),
1427 }
1428 std::fs::remove_dir_all(dir).ok();
1429 }
1430
1431 #[test]
1432 fn missing_image_path_emits_mos0037() {
1433 let r = lower("#image()\n", &PathBuf::from("/tmp/no-such.mos"));
1434 assert!(
1435 r.diagnostics
1436 .iter()
1437 .any(|d| d.def().code() == codes::MOS0037.code()),
1438 "expected MOS0037, got {:?}",
1439 r.diagnostics
1440 );
1441 }
1442
1443 #[test]
1444 fn unreadable_image_emits_mos0012() {
1445 let r = lower(
1446 "#image(\"does-not-exist.png\")\n",
1447 &PathBuf::from("/tmp/no-such-dir/main.mos"),
1448 );
1449 assert!(
1450 r.diagnostics
1451 .iter()
1452 .any(|d| d.def().code() == codes::MOS0012.code()),
1453 "expected MOS0012, got {:?}",
1454 r.diagnostics
1455 );
1456 }
1457
1458 #[test]
1459 fn empty_image_path_emits_mos0037_not_io_error() {
1460 let r = lower("#image(\"\")\n", &PathBuf::from("/tmp/whatever/main.mos"));
1465 assert!(
1466 r.diagnostics
1467 .iter()
1468 .any(|d| d.def().code() == codes::MOS0037.code()),
1469 "expected MOS0037, got {:?}",
1470 r.diagnostics
1471 );
1472 assert!(
1474 !r.diagnostics.iter().any(|d| {
1475 d.def().code() == codes::MOS0012.code() || d.def().code() == codes::MOS0029.code()
1476 }),
1477 "unexpected I/O diagnostic: {:?}",
1478 r.diagnostics
1479 );
1480 }
1481
1482 #[test]
1483 fn non_positive_image_width_emits_mos0020() {
1484 for src in [
1488 "#image(\"x.png\", width: 0pt)\n",
1489 "#image(\"x.png\", width: -10pt)\n",
1490 "#image(\"x.png\", width: 0)\n",
1491 "#image(\"x.png\", width: -1)\n",
1492 ] {
1493 let r = lower(src, &PathBuf::from("/tmp/whatever/main.mos"));
1494 assert!(
1495 r.diagnostics
1496 .iter()
1497 .any(|d| d.def().code() == codes::MOS0020.code()),
1498 "expected MOS0020 for `{src}`, got {:?}",
1499 r.diagnostics
1500 );
1501 }
1502 }
1503
1504 #[test]
1505 fn non_positive_image_height_emits_mos0020() {
1506 for src in [
1507 "#image(\"x.png\", height: 0pt)\n",
1508 "#image(\"x.png\", height: -1mm)\n",
1509 ] {
1510 let r = lower(src, &PathBuf::from("/tmp/whatever/main.mos"));
1511 assert!(
1512 r.diagnostics
1513 .iter()
1514 .any(|d| d.def().code() == codes::MOS0020.code()),
1515 "expected MOS0020 for `{src}`, got {:?}",
1516 r.diagnostics
1517 );
1518 }
1519 }
1520
1521 #[test]
1522 fn undecodable_image_emits_mos0029() {
1523 let dir = std::env::temp_dir().join(format!(
1524 "mos-eval-bad-{}",
1525 std::time::SystemTime::now()
1526 .duration_since(std::time::UNIX_EPOCH)
1527 .map_or(0, |d| d.as_nanos())
1528 ));
1529 std::fs::create_dir_all(&dir).unwrap();
1530 let png = dir.join("bad.png");
1531 std::fs::write(&png, b"not really a PNG").unwrap();
1532 let source = dir.join("main.mos");
1533 std::fs::write(&source, "#image(\"bad.png\")\n").unwrap();
1534 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1535 assert!(
1536 r.diagnostics
1537 .iter()
1538 .any(|d| d.def().code() == codes::MOS0029.code()),
1539 "expected MOS0029, got {:?}",
1540 r.diagnostics
1541 );
1542 std::fs::remove_dir_all(&dir).ok();
1543 }
1544
1545 #[test]
1546 fn figure_directive_creates_figure_with_image_and_caption() {
1547 let png_path = write_tiny_png("fig.png");
1548 let source = png_path.parent().unwrap().join("main.mos");
1549 std::fs::write(
1550 &source,
1551 "#figure(image: \"fig.png\", caption: \"A tiny picture.\")\n",
1552 )
1553 .unwrap();
1554 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1555 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1556 let figure = r
1557 .document
1558 .nodes()
1559 .find(|n| n.kind == NodeKind::Figure)
1560 .expect("Figure node");
1561 assert_eq!(figure.children.len(), 2);
1562 let img = r.document.get(figure.children[0]).unwrap();
1563 assert_eq!(img.kind, NodeKind::Image);
1564 let caption = r.document.get(figure.children[1]).unwrap();
1565 assert_eq!(caption.kind, NodeKind::Paragraph);
1566 assert_eq!(
1567 caption.attributes.get("role"),
1568 Some(&AttrValue::Str("caption".to_owned()))
1569 );
1570 let caption_text = r.document.get(caption.children[0]).unwrap();
1573 assert_eq!(
1574 caption_text.attributes.get("text"),
1575 Some(&AttrValue::Str(
1576 "Figure\u{00A0}1: A tiny picture.".to_owned()
1577 ))
1578 );
1579 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1580 }
1581
1582 #[test]
1583 fn figure_with_missing_image_does_not_leak_empty_node() {
1584 let r = lower(
1591 "#figure(image: \"does-not-exist.png\", caption: \"missing\")\n",
1592 &PathBuf::from("/tmp/no-such-dir/main.mos"),
1593 );
1594 assert!(
1595 r.diagnostics
1596 .iter()
1597 .any(|d| d.def().code() == codes::MOS0012.code())
1598 );
1599 assert!(
1600 !r.document.nodes().any(|n| n.kind == NodeKind::Figure),
1601 "Figure node leaked after image load failure",
1602 );
1603 }
1604
1605 #[test]
1606 fn figure_label_reference_resolves_to_figure_number() {
1607 let png_path = write_tiny_png("ref-fig.png");
1613 let dir = png_path.parent().unwrap();
1614 let source = dir.join("main.mos");
1615 std::fs::write(
1616 &source,
1617 "#figure(image: \"ref-fig.png\", caption: \"A plot.\", label: \"fig:plot\")\n\nSee @fig:plot here.\n",
1618 )
1619 .unwrap();
1620 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1621 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1622
1623 let figure = r
1624 .document
1625 .nodes()
1626 .find(|n| n.kind == NodeKind::Figure)
1627 .expect("Figure node");
1628 assert_eq!(
1629 figure.attributes.get("number"),
1630 Some(&AttrValue::Str("1".to_owned())),
1631 "the lowered figure is numbered in document order"
1632 );
1633 assert_eq!(
1634 figure.attributes.get("label"),
1635 Some(&AttrValue::Str("fig:plot".to_owned())),
1636 "the `label:` argument lands on the Figure node"
1637 );
1638
1639 let caption_text = figure
1641 .children
1642 .iter()
1643 .filter_map(|c| r.document.get(*c))
1644 .find(|c| {
1645 matches!(c.attributes.get("role"), Some(AttrValue::Str(role)) if role == "caption")
1646 })
1647 .and_then(|caption| caption.children.first())
1648 .and_then(|text_id| r.document.get(*text_id))
1649 .and_then(|text| text.attributes.get("text"));
1650 assert_eq!(
1651 caption_text,
1652 Some(&AttrValue::Str("Figure\u{00A0}1: A plot.".to_owned())),
1653 "the caption is prefixed with the `Figure N: ` label"
1654 );
1655
1656 let reference = r
1657 .document
1658 .nodes()
1659 .find(|n| n.kind == NodeKind::Reference)
1660 .expect("Reference node");
1661 assert_eq!(
1662 reference.attributes.get("text"),
1663 Some(&AttrValue::Str("Figure\u{00A0}1".to_owned())),
1664 "the `@fig:plot` reference resolves to kind-aware figure text"
1665 );
1666
1667 std::fs::remove_dir_all(dir).ok();
1668 }
1669
1670 #[test]
1671 fn page_reference_lowers_to_inert_page_reference_node() {
1672 let r = lower(
1680 "= Intro <intro>\n\nSee @page(intro) here.\n",
1681 &PathBuf::from("test.mos"),
1682 );
1683 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1684
1685 let page_ref = r
1686 .document
1687 .nodes()
1688 .find(|n| n.kind == NodeKind::PageReference)
1689 .expect("PageReference node");
1690 assert_eq!(
1691 page_ref.attributes.get("label"),
1692 Some(&AttrValue::Str("intro".to_owned())),
1693 );
1694 assert_eq!(
1695 page_ref.attributes.get("text"),
1696 Some(&AttrValue::Str("?intro?".to_owned())),
1697 "unresolved page references keep a visible placeholder",
1698 );
1699 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1701 }
1702
1703 #[test]
1704 fn undeclared_page_reference_label_emits_mos0033() {
1705 let r = lower("See @page(missing) here.\n", &PathBuf::from("test.mos"));
1708 assert!(
1709 r.diagnostics
1710 .iter()
1711 .any(|d| d.def().code() == codes::MOS0033.code()),
1712 "{:?}",
1713 r.diagnostics
1714 );
1715 let page_ref = r
1717 .document
1718 .nodes()
1719 .find(|n| n.kind == NodeKind::PageReference)
1720 .expect("PageReference node");
1721 assert_eq!(
1722 page_ref.attributes.get("text"),
1723 Some(&AttrValue::Str("?missing?".to_owned())),
1724 );
1725 }
1726
1727 #[test]
1728 fn page_reference_to_a_declared_label_is_not_a_duplicate_declaration() {
1729 let r = lower(
1732 "= Intro <intro>\n\nSee @page(intro) here.\n",
1733 &PathBuf::from("test.mos"),
1734 );
1735 assert!(
1736 !r.diagnostics
1737 .iter()
1738 .any(|d| d.def().code() == codes::MOS0030.code()),
1739 "{:?}",
1740 r.diagnostics
1741 );
1742 }
1743
1744 #[test]
1745 fn citation_lowers_to_citation_node_with_key_and_span() {
1746 let src = "see [@smith2024] here\n";
1753 let r = lower(src, &PathBuf::from("test.mos"));
1754 assert!(
1755 r.diagnostics
1756 .iter()
1757 .any(|d| d.def().code() == codes::MOS0045.code()),
1758 "expected MOS0045 because no bibliography records are declared, got {:?}",
1759 r.diagnostics
1760 );
1761 let citation = r
1762 .document
1763 .nodes()
1764 .find(|n| n.kind == NodeKind::Citation)
1765 .expect("citation node");
1766 assert_eq!(
1767 citation.attributes.get("key"),
1768 Some(&AttrValue::Str("smith2024".to_owned())),
1769 );
1770 assert_eq!(
1771 citation.attributes.get("text"),
1772 Some(&AttrValue::Str("[?smith2024?]".to_owned())),
1773 );
1774 let span_text = &src[citation.span.start()..citation.span.end()];
1775 assert_eq!(span_text, "[@smith2024]");
1776 }
1777
1778 #[test]
1779 fn malformed_citation_does_not_create_citation_node() {
1780 let r = lower("look [@] here\n", &PathBuf::from("test.mos"));
1784 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1785 assert!(
1786 r.diagnostics
1787 .iter()
1788 .any(|d| d.def().code() == codes::MOS0039.code()),
1789 "expected MOS0039, got {:?}",
1790 r.diagnostics,
1791 );
1792 assert!(
1793 !r.document.nodes().any(|n| n.kind == NodeKind::Citation),
1794 "no Citation nodes expected, got {:?}",
1795 r.document.nodes().map(|n| n.kind).collect::<Vec<_>>(),
1796 );
1797 }
1798
1799 #[test]
1800 fn unterminated_citation_does_not_leak_into_reference_resolver() {
1801 let r = lower(
1809 "see [@smith2024 missing close\n",
1810 &PathBuf::from("test.mos"),
1811 );
1812 assert!(
1813 !r.has_errors(),
1814 "no errors expected, got {:?}",
1815 r.diagnostics,
1816 );
1817 assert!(
1818 r.diagnostics
1819 .iter()
1820 .any(|d| d.def().code() == codes::MOS0039.code()),
1821 "expected MOS0039, got {:?}",
1822 r.diagnostics,
1823 );
1824 assert!(
1825 !r.diagnostics
1826 .iter()
1827 .any(|d| d.def().code() == codes::MOS0033.code()),
1828 "malformed citation must not surface as unknown-label MOS0033: {:?}",
1829 r.diagnostics,
1830 );
1831 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Citation));
1832 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1833 }
1834
1835 #[test]
1836 fn deferred_multi_key_citation_does_not_leak_into_reference_resolver() {
1837 let r = lower(
1842 "compare [@smith2024; @jones2025] now\n",
1843 &PathBuf::from("test.mos"),
1844 );
1845 assert!(
1846 !r.has_errors(),
1847 "no errors expected, got {:?}",
1848 r.diagnostics,
1849 );
1850 assert!(
1851 r.diagnostics
1852 .iter()
1853 .any(|d| d.def().code() == codes::MOS0039.code())
1854 );
1855 assert!(
1856 !r.diagnostics
1857 .iter()
1858 .any(|d| d.def().code() == codes::MOS0033.code()),
1859 "multi-key citation must not surface as unknown-label MOS0033: {:?}",
1860 r.diagnostics,
1861 );
1862 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Citation));
1863 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1864 }
1865
1866 #[test]
1867 fn figure_directive_accepts_positional_path() {
1868 let png_path = write_tiny_png("fig_pos.png");
1872 let source = png_path.parent().unwrap().join("main.mos");
1873 std::fs::write(&source, "#figure(\"fig_pos.png\")\n").unwrap();
1874 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1875 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1876 let figure = r
1877 .document
1878 .nodes()
1879 .find(|n| n.kind == NodeKind::Figure)
1880 .expect("Figure node");
1881 assert_eq!(figure.children.len(), 1);
1883 let img = r.document.get(figure.children[0]).unwrap();
1884 assert_eq!(img.kind, NodeKind::Image);
1885 assert_eq!(
1886 img.attributes.get("src"),
1887 Some(&AttrValue::Str("fig_pos.png".to_owned()))
1888 );
1889 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1890 }
1891
1892 fn unique_temp_dir(name: &str) -> PathBuf {
1896 let dir = std::env::temp_dir().join(format!(
1897 "mos-eval-bib-{}-{}",
1898 name,
1899 std::time::SystemTime::now()
1900 .duration_since(std::time::UNIX_EPOCH)
1901 .map_or(0, |d| d.as_nanos())
1902 ));
1903 std::fs::create_dir_all(&dir).unwrap();
1904 dir
1905 }
1906
1907 #[test]
1908 fn bibliography_directive_preserves_resolved_path() {
1909 let dir = unique_temp_dir("preserve");
1914 let bib = dir.join("refs.bib");
1915 std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1916 let source = dir.join("main.mos");
1917 std::fs::write(&source, "#bibliography(\"refs.bib\")\n").unwrap();
1918 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1919 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1920 let node = r
1921 .document
1922 .nodes()
1923 .find(|n| n.kind == NodeKind::Bibliography)
1924 .expect("Bibliography node");
1925 assert_eq!(
1926 node.attributes.get("src"),
1927 Some(&AttrValue::Str("refs.bib".to_owned()))
1928 );
1929 assert_eq!(
1930 node.attributes.get("resolved_path"),
1931 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1932 );
1933 std::fs::remove_dir_all(&dir).ok();
1934 }
1935
1936 #[test]
1937 fn lowering_exposes_the_loaded_bibliography_records() {
1938 let dir = unique_temp_dir("records");
1939 std::fs::write(dir.join("a.bib"), "@book{alpha, title={A}}\n").unwrap();
1940 std::fs::write(dir.join("b.bib"), "@article{beta, title={B}}\n").unwrap();
1941 let source = dir.join("main.mos");
1942 let r = lower(
1943 "#bibliography(\"a.bib\")\n\n#bibliography(\"b.bib\")\n\n#bibliography(\"missing.bib\")\n",
1944 &source,
1945 );
1946 let keys: Vec<&str> = r.bibliography.entries.keys().map(String::as_str).collect();
1947 assert_eq!(keys, ["alpha", "beta"]);
1948 assert_eq!(r.bibliography.entries["beta"].entry_type, "article");
1949 assert!(!r.bibliography_complete);
1950 assert!(
1951 r.diagnostics
1952 .iter()
1953 .any(|d| d.def().code() == codes::MOS0041.code()),
1954 "{:?}",
1955 r.diagnostics
1956 );
1957 std::fs::write(dir.join("missing.bib"), "@book{gamma, title={C}}\n").unwrap();
1958 let complete = lower(
1959 "#bibliography(\"a.bib\")\n\n#bibliography(\"b.bib\")\n\n#bibliography(\"missing.bib\")\n",
1960 &source,
1961 );
1962 assert!(complete.bibliography_complete);
1963 assert_eq!(complete.bibliography.entries.len(), 3);
1964 std::fs::remove_dir_all(&dir).ok();
1965 }
1966
1967 #[test]
1968 fn bibliography_named_path_resolves_against_source_dir() {
1969 let dir = unique_temp_dir("named");
1972 let sub = dir.join("sources");
1973 std::fs::create_dir_all(&sub).unwrap();
1974 let bib = sub.join("refs.bib");
1975 std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1976 let source = dir.join("main.mos");
1977 std::fs::write(&source, "#bibliography(path: \"sources/refs.bib\")\n").unwrap();
1978 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1979 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1980 let node = r
1981 .document
1982 .nodes()
1983 .find(|n| n.kind == NodeKind::Bibliography)
1984 .expect("Bibliography node");
1985 assert_eq!(
1986 node.attributes.get("resolved_path"),
1987 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1988 );
1989 std::fs::remove_dir_all(&dir).ok();
1990 }
1991
1992 #[test]
1993 fn bibliography_src_alias_resolves_against_source_dir() {
1994 let dir = unique_temp_dir("src-alias");
1997 let sub = dir.join("sources");
1998 std::fs::create_dir_all(&sub).unwrap();
1999 let bib = sub.join("refs.bib");
2000 std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
2001 let source = dir.join("main.mos");
2002 std::fs::write(&source, "#bibliography(src: \"sources/refs.bib\")\n").unwrap();
2003 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2004 assert!(!r.has_errors(), "{:?}", r.diagnostics);
2005 let node = r
2006 .document
2007 .nodes()
2008 .find(|n| n.kind == NodeKind::Bibliography)
2009 .expect("Bibliography node");
2010 assert_eq!(
2011 node.attributes.get("src"),
2012 Some(&AttrValue::Str("sources/refs.bib".to_owned()))
2013 );
2014 assert_eq!(
2015 node.attributes.get("resolved_path"),
2016 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
2017 );
2018 std::fs::remove_dir_all(&dir).ok();
2019 }
2020
2021 #[test]
2022 fn known_citation_key_resolves_against_bibliography_records() {
2023 let dir = unique_temp_dir("citation-known");
2027 let bib = dir.join("refs.bib");
2028 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2029 let source = dir.join("main.mos");
2030 let source_text =
2031 "#bibliography(\"refs.bib\")\n\n= Intro <intro>\n\nsee [@smith2024] and @intro\n";
2032 std::fs::write(&source, source_text).unwrap();
2033
2034 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2035 assert!(!r.has_errors(), "{:?}", r.diagnostics);
2036
2037 let citation = r
2038 .document
2039 .nodes()
2040 .find(|n| n.kind == NodeKind::Citation)
2041 .expect("Citation node");
2042 assert_eq!(
2043 citation.attributes.get("resolved"),
2044 Some(&AttrValue::Bool(true)),
2045 "known key should be marked resolved for later rendering"
2046 );
2047 assert_eq!(
2048 citation.attributes.get("text"),
2049 Some(&AttrValue::Str("[1]".to_owned())),
2050 "a resolved citation renders its first-use numeric label"
2051 );
2052 assert_eq!(
2053 citation.attributes.get("target_path"),
2054 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
2055 );
2056 assert_eq!(
2057 citation.attributes.get("target_span.start"),
2058 Some(&AttrValue::Int(9))
2059 );
2060 assert_eq!(
2061 citation.attributes.get("target_span.end"),
2062 Some(&AttrValue::Int(18))
2063 );
2064
2065 let reference = r
2066 .document
2067 .nodes()
2068 .find(|n| n.kind == NodeKind::Reference)
2069 .expect("Reference node");
2070 assert_eq!(
2071 reference.attributes.get("text"),
2072 Some(&AttrValue::Str("1".to_owned())),
2073 "label references still resolve while citations are checked"
2074 );
2075
2076 std::fs::remove_dir_all(&dir).ok();
2077 }
2078
2079 fn bibliography_entries(document: &Document) -> Vec<(i64, String, String)> {
2082 let bib = document
2083 .nodes()
2084 .find(|n| n.kind == NodeKind::Bibliography)
2085 .expect("Bibliography node");
2086 bib.children
2087 .iter()
2088 .filter_map(|id| document.get(*id))
2089 .map(|entry| {
2090 let Some(AttrValue::Int(number)) = entry.attributes.get("entry_number") else {
2091 panic!("entry paragraph without entry_number: {entry:?}");
2092 };
2093 let Some(AttrValue::Str(key)) = entry.attributes.get("entry_key") else {
2094 panic!("entry paragraph without entry_key: {entry:?}");
2095 };
2096 let text = entry
2097 .children
2098 .iter()
2099 .filter_map(|id| document.get(*id))
2100 .find_map(|child| match child.attributes.get("text") {
2101 Some(AttrValue::Str(text)) => Some(text.clone()),
2102 _ => None,
2103 })
2104 .expect("entry paragraph without a Text child");
2105 (*number, key.clone(), text)
2106 })
2107 .collect()
2108 }
2109
2110 #[test]
2111 fn cited_entries_render_in_first_use_order() {
2112 let dir = unique_temp_dir("entries-order");
2116 std::fs::write(
2117 dir.join("refs.bib"),
2118 "@book{a, title={Alpha}, author={Ada Author}, year={1990}}\n\
2119 @book{b, title={Beta}, publisher={Beta House}, year={1991}}\n",
2120 )
2121 .unwrap();
2122 let source = dir.join("main.mos");
2123 std::fs::write(
2124 &source,
2125 "#bibliography(\"refs.bib\")\n\nsee [@b] then [@a]\n",
2126 )
2127 .unwrap();
2128
2129 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2130 assert!(!r.has_errors(), "{:?}", r.diagnostics);
2131
2132 let entries = bibliography_entries(&r.document);
2133 assert_eq!(entries.len(), 2, "{entries:?}");
2134 assert_eq!(entries[0].0, 1);
2135 assert_eq!(entries[0].1, "b");
2136 assert_eq!(entries[0].2, "Beta. Beta House. 1991.");
2137 assert_eq!(entries[1].0, 2);
2138 assert_eq!(entries[1].1, "a");
2139 assert_eq!(entries[1].2, "Ada Author. Alpha. 1990.");
2140
2141 std::fs::remove_dir_all(&dir).ok();
2142 }
2143
2144 #[test]
2145 fn repeated_and_uncited_keys_render_one_entry_each_or_none() {
2146 let dir = unique_temp_dir("entries-dedup");
2150 std::fs::write(
2151 dir.join("refs.bib"),
2152 "@book{a, title={Alpha}}\n@book{b, title={Beta}}\n",
2153 )
2154 .unwrap();
2155 let source = dir.join("main.mos");
2156 std::fs::write(
2157 &source,
2158 "#bibliography(\"refs.bib\")\n\nsee [@a] and [@a] again\n",
2159 )
2160 .unwrap();
2161
2162 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2163 assert!(!r.has_errors(), "{:?}", r.diagnostics);
2164
2165 let entries = bibliography_entries(&r.document);
2166 assert_eq!(entries.len(), 1, "{entries:?}");
2167 assert_eq!(entries[0], (1, "a".to_owned(), "Alpha.".to_owned()));
2168
2169 std::fs::remove_dir_all(&dir).ok();
2170 }
2171
2172 #[test]
2173 fn unresolved_keys_produce_no_bibliography_entries() {
2174 let dir = unique_temp_dir("entries-unresolved");
2177 std::fs::write(dir.join("refs.bib"), "@book{real, title={Real}}\n").unwrap();
2178 let source = dir.join("main.mos");
2179 std::fs::write(&source, "#bibliography(\"refs.bib\")\n\nsee [@phantom]\n").unwrap();
2180
2181 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2182 assert!(
2183 r.diagnostics
2184 .iter()
2185 .any(|d| d.def().code() == codes::MOS0045.code()),
2186 "unknown key keeps MOS0045: {:?}",
2187 r.diagnostics
2188 );
2189
2190 let entries = bibliography_entries(&r.document);
2191 assert!(entries.is_empty(), "{entries:?}");
2192
2193 std::fs::remove_dir_all(&dir).ok();
2194 }
2195
2196 fn mos0049_diagnostic(diagnostics: &[Diagnostic]) -> &Diagnostic {
2197 let found: Vec<&Diagnostic> = diagnostics
2198 .iter()
2199 .filter(|d| d.def().code() == codes::MOS0049.code())
2200 .collect();
2201 assert_eq!(found.len(), 1, "exactly one MOS0049, got {found:?}");
2202 found[0]
2203 }
2204
2205 #[test]
2206 fn unsafe_named_image_and_bibliography_paths_suggest_forward_slashes() {
2207 let source_text = "#image(src: \"a\\\\b.png\")\n#bibliography(path: \"c\\\\d.bib\")\n";
2208 let r = lower(source_text, &PathBuf::from("test.mos"));
2209 let fixes: Vec<(String, &str)> = r
2210 .diagnostics
2211 .iter()
2212 .filter(|d| d.def().code() == codes::MOS0049.code())
2213 .flat_map(|d| d.suggestions().iter())
2214 .map(|s| {
2215 (
2216 s.replacement.clone(),
2217 &source_text[s.span.start()..s.span.end()],
2218 )
2219 })
2220 .collect();
2221 assert_eq!(
2222 fixes,
2223 vec![
2224 ("a/b.png".to_owned(), r"a\\b.png"),
2225 ("c/d.bib".to_owned(), r"c\\d.bib"),
2226 ]
2227 );
2228 }
2229
2230 #[test]
2231 fn unsafe_path_fix_re_escapes_the_literal_contents() {
2232 let source_text = "#image(\"a\\\"b\\\\c.png\")\n";
2233 let r = lower(source_text, &PathBuf::from("test.mos"));
2234 let diag = mos0049_diagnostic(&r.diagnostics);
2235 let suggestions = diag.suggestions();
2236 assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2237 assert_eq!(suggestions[0].replacement, r#"a\"b/c.png"#);
2238 assert_eq!(
2239 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2240 r#"a\"b\\c.png"#
2241 );
2242 }
2243
2244 #[test]
2245 fn unsafe_image_path_suggests_forward_slashes() {
2246 let source_text = "#image(\"assets\\\\logo.png\")\n";
2247 let r = lower(source_text, &PathBuf::from("test.mos"));
2248 let diag = mos0049_diagnostic(&r.diagnostics);
2249 let suggestions = diag.suggestions();
2250 assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2251 assert_eq!(suggestions[0].replacement, "assets/logo.png");
2252 assert_eq!(
2253 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2254 r"assets\\logo.png",
2255 "the fix replaces the literal's contents, quotes excluded"
2256 );
2257 }
2258
2259 #[test]
2260 fn unsafe_figure_image_path_suggests_forward_slashes() {
2261 let source_text = "#figure(image: \"img\\\\a.png\", caption: \"c\")\n";
2262 let r = lower(source_text, &PathBuf::from("test.mos"));
2263 let diag = mos0049_diagnostic(&r.diagnostics);
2264 let suggestions = diag.suggestions();
2265 assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2266 assert_eq!(suggestions[0].replacement, "img/a.png");
2267 assert_eq!(
2268 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2269 r"img\\a.png"
2270 );
2271 }
2272
2273 #[test]
2274 fn unsafe_bibliography_path_suggests_forward_slashes() {
2275 let source_text = "#bibliography(\"refs\\\\x.bib\")\n";
2276 let r = lower(source_text, &PathBuf::from("test.mos"));
2277 let diag = mos0049_diagnostic(&r.diagnostics);
2278 let suggestions = diag.suggestions();
2279 assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2280 assert_eq!(suggestions[0].replacement, "refs/x.bib");
2281 assert_eq!(
2282 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2283 r"refs\\x.bib"
2284 );
2285 }
2286
2287 #[test]
2288 fn drive_prefixed_image_path_gets_no_suggestion() {
2289 let source_text = "#image(\"C:\\\\x.png\")\n";
2290 let r = lower(source_text, &PathBuf::from("test.mos"));
2291 let diag = mos0049_diagnostic(&r.diagnostics);
2292 assert!(
2293 diag.suggestions().is_empty(),
2294 "no guess for a drive-prefixed path, got {:?}",
2295 diag.suggestions()
2296 );
2297 }
2298
2299 #[test]
2300 fn label_reference_matching_bib_key_suggests_citation() {
2301 let dir = unique_temp_dir("ref-is-bibkey");
2305 let bib = dir.join("refs.bib");
2306 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2307 let source = dir.join("main.mos");
2308 let source_text = "#bibliography(\"refs.bib\")\n\nsee @smith2024 here\n";
2309 std::fs::write(&source, source_text).unwrap();
2310
2311 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2312
2313 let diag = r
2314 .diagnostics
2315 .iter()
2316 .find(|d| d.def().code() == codes::MOS0033.code())
2317 .expect("MOS0033 for the @smith2024 reference");
2318 let suggestions = diag.suggestions();
2319 assert_eq!(
2320 suggestions.len(),
2321 1,
2322 "one citation fix, got {suggestions:?}"
2323 );
2324 assert_eq!(suggestions[0].replacement, "[@smith2024]");
2325 assert_eq!(
2326 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2327 "@smith2024",
2328 "the fix replaces the whole `@key` token, sigil included"
2329 );
2330
2331 std::fs::remove_dir_all(&dir).ok();
2332 }
2333
2334 #[test]
2335 fn repeated_known_citation_key_reuses_its_first_number() {
2336 let dir = unique_temp_dir("citation-repeat");
2339 let bib = dir.join("refs.bib");
2340 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2341 let source = dir.join("main.mos");
2342 let source_text =
2343 "#bibliography(\"refs.bib\")\n\nsee [@smith2024] and again [@smith2024]\n";
2344 std::fs::write(&source, source_text).unwrap();
2345
2346 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2347 assert!(!r.has_errors(), "{:?}", r.diagnostics);
2348
2349 let labels: Vec<Option<AttrValue>> = r
2350 .document
2351 .nodes()
2352 .filter(|n| n.kind == NodeKind::Citation)
2353 .map(|n| n.attributes.get("text").cloned())
2354 .collect();
2355 assert_eq!(
2356 labels,
2357 vec![
2358 Some(AttrValue::Str("[1]".to_owned())),
2359 Some(AttrValue::Str("[1]".to_owned())),
2360 ],
2361 "repeated key reuses its first-use number"
2362 );
2363
2364 std::fs::remove_dir_all(&dir).ok();
2365 }
2366
2367 #[test]
2368 fn distinct_known_citation_keys_number_by_first_use_order() {
2369 let dir = unique_temp_dir("citation-order");
2373 let bib = dir.join("refs.bib");
2374 std::fs::write(
2376 &bib,
2377 "@article{alpha, title={A}}\n@article{beta, title={B}}\n",
2378 )
2379 .unwrap();
2380 let source = dir.join("main.mos");
2381 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@beta] then [@alpha] and [@beta]\n";
2383 std::fs::write(&source, source_text).unwrap();
2384
2385 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2386 assert!(!r.has_errors(), "{:?}", r.diagnostics);
2387
2388 let labels: Vec<Option<AttrValue>> = r
2389 .document
2390 .nodes()
2391 .filter(|n| n.kind == NodeKind::Citation)
2392 .map(|n| n.attributes.get("text").cloned())
2393 .collect();
2394 assert_eq!(
2395 labels,
2396 vec![
2397 Some(AttrValue::Str("[1]".to_owned())),
2398 Some(AttrValue::Str("[2]".to_owned())),
2399 Some(AttrValue::Str("[1]".to_owned())),
2400 ],
2401 "numbering follows first citation, not bibliography source order"
2402 );
2403
2404 std::fs::remove_dir_all(&dir).ok();
2405 }
2406
2407 #[test]
2408 fn unknown_citation_key_emits_mos0045_with_source_span() {
2409 let dir = unique_temp_dir("citation-unknown");
2410 let bib = dir.join("refs.bib");
2411 std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
2412 let source = dir.join("main.mos");
2413 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@missing]\n";
2414 std::fs::write(&source, source_text).unwrap();
2415
2416 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2417 let missing: Vec<&Diagnostic> = r
2418 .diagnostics
2419 .iter()
2420 .filter(|d| d.def().code() == codes::MOS0045.code())
2421 .collect();
2422 assert_eq!(
2423 missing.len(),
2424 1,
2425 "expected one MOS0045, got {:?}",
2426 r.diagnostics
2427 );
2428 let diagnostic = missing[0];
2429 assert!(
2430 diagnostic.message().contains("`missing`"),
2431 "diagnostic should name missing citation key, got {:?}",
2432 diagnostic.message()
2433 );
2434 assert_eq!(
2435 diagnostic
2436 .span()
2437 .map(|span| &source_text[span.start()..span.end()]),
2438 Some("[@missing]"),
2439 "MOS0045 should point at the citation token"
2440 );
2441
2442 let citation = r
2443 .document
2444 .nodes()
2445 .find(|n| n.kind == NodeKind::Citation)
2446 .expect("Citation node");
2447 assert_eq!(
2448 citation.attributes.get("text"),
2449 Some(&AttrValue::Str("[?missing?]".to_owned())),
2450 "unknown citations keep visible placeholder text"
2451 );
2452 assert_eq!(citation.attributes.get("resolved"), None);
2453
2454 std::fs::remove_dir_all(&dir).ok();
2455 }
2456
2457 #[test]
2458 fn unknown_citation_key_suggests_nearest_loaded_key() {
2459 let dir = unique_temp_dir("citation-nearest-key");
2460 let bib = dir.join("refs.bib");
2461 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2462 let source = dir.join("main.mos");
2463 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@smit2024]\n";
2464 std::fs::write(&source, source_text).unwrap();
2465
2466 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2467 let diagnostic = r
2468 .diagnostics
2469 .iter()
2470 .find(|d| d.def().code() == codes::MOS0045.code())
2471 .expect("MOS0045 for missing citation key");
2472 let suggestions = diagnostic.suggestions();
2473 assert_eq!(
2474 suggestions.len(),
2475 1,
2476 "expected one nearest-key suggestion, got {suggestions:?}"
2477 );
2478 assert_eq!(suggestions[0].replacement, "smith2024");
2479 assert_eq!(
2480 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2481 "smit2024",
2482 "suggestion should replace only the citation key token"
2483 );
2484
2485 std::fs::remove_dir_all(&dir).ok();
2486 }
2487
2488 #[test]
2489 fn unknown_citation_key_tie_has_no_suggestion() {
2490 let dir = unique_temp_dir("citation-nearest-key-tie");
2491 let bib = dir.join("refs.bib");
2492 std::fs::write(&bib, "@article{abx, title={X}}\n@article{aby, title={Y}}\n").unwrap();
2493 let source = dir.join("main.mos");
2494 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@abc]\n";
2495 std::fs::write(&source, source_text).unwrap();
2496
2497 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2498 let diagnostic = r
2499 .diagnostics
2500 .iter()
2501 .find(|d| d.def().code() == codes::MOS0045.code())
2502 .expect("MOS0045 for missing citation key");
2503 assert!(
2504 diagnostic.suggestions().is_empty(),
2505 "ties should not produce a guess: {:?}",
2506 diagnostic.suggestions()
2507 );
2508
2509 std::fs::remove_dir_all(&dir).ok();
2510 }
2511
2512 #[test]
2513 fn multiple_unknown_citations_emit_deterministic_mos0045_diagnostics() {
2514 let dir = unique_temp_dir("citation-multiple-unknown");
2515 let bib = dir.join("refs.bib");
2516 std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
2517 let source = dir.join("main.mos");
2518 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@alpha] and [@beta]\n";
2519 std::fs::write(&source, source_text).unwrap();
2520
2521 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2522 let spans: Vec<&str> = r
2523 .diagnostics
2524 .iter()
2525 .filter(|d| d.def().code() == codes::MOS0045.code())
2526 .filter_map(|d| d.span().map(|span| &source_text[span.start()..span.end()]))
2527 .collect();
2528 assert_eq!(
2529 spans,
2530 vec!["[@alpha]", "[@beta]"],
2531 "unknown citation diagnostics should follow document order"
2532 );
2533
2534 std::fs::remove_dir_all(&dir).ok();
2535 }
2536
2537 #[test]
2538 fn incomplete_bibliography_sources_do_not_emit_false_missing_citations() {
2539 let dir = unique_temp_dir("citation-incomplete-bibliography");
2540 let bib = dir.join("refs.bib");
2541 std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
2542 let source = dir.join("main.mos");
2543 let source_text = "#bibliography(\"refs.bib\")\n#bibliography(\"missing.bib\")\n\nsee [@known] and [@maybe-in-missing]\n";
2544 std::fs::write(&source, source_text).unwrap();
2545
2546 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2547 assert!(
2548 r.diagnostics
2549 .iter()
2550 .any(|d| d.def().code() == codes::MOS0041.code()),
2551 "expected missing bibliography source warning, got {:?}",
2552 r.diagnostics
2553 );
2554 assert!(
2555 !r.diagnostics
2556 .iter()
2557 .any(|d| d.def().code() == codes::MOS0045.code()),
2558 "incomplete bibliography set must not produce false MOS0045 diagnostics"
2559 );
2560
2561 let known = r
2562 .document
2563 .nodes()
2564 .filter(|n| n.kind == NodeKind::Citation)
2565 .find(|n| n.attributes.get("key") == Some(&AttrValue::Str("known".to_owned())))
2566 .expect("known citation node");
2567 assert_eq!(
2568 known.attributes.get("resolved"),
2569 Some(&AttrValue::Bool(true))
2570 );
2571
2572 std::fs::remove_dir_all(&dir).ok();
2573 }
2574
2575 #[test]
2576 fn duplicate_citation_keys_across_bibliography_sources_emit_mos0046() {
2577 let dir = unique_temp_dir("citation-duplicate-key");
2578 let first = dir.join("first.bib");
2579 let second = dir.join("second.bib");
2580 std::fs::write(&first, "@article{dup, title={First}}\n").unwrap();
2581 std::fs::write(&second, "@book{dup, title={Second}}\n").unwrap();
2582 let source = dir.join("main.mos");
2583 let source_text =
2584 "#bibliography(\"first.bib\")\n#bibliography(\"second.bib\")\n\nsee [@dup]\n";
2585 std::fs::write(&source, source_text).unwrap();
2586
2587 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2588 let duplicates: Vec<&Diagnostic> = r
2589 .diagnostics
2590 .iter()
2591 .filter(|d| d.def().code() == codes::MOS0046.code())
2592 .collect();
2593 assert_eq!(
2594 duplicates.len(),
2595 1,
2596 "expected one MOS0046, got {:?}",
2597 r.diagnostics
2598 );
2599 let diagnostic = duplicates[0];
2600 assert!(
2601 diagnostic.message().contains("`dup`"),
2602 "diagnostic should name duplicate citation key, got {:?}",
2603 diagnostic.message()
2604 );
2605 assert_eq!(
2606 diagnostic
2607 .span()
2608 .map(|span| &source_text[span.start()..span.end()]),
2609 Some("#bibliography(\"second.bib\")"),
2610 "duplicate should point at the later bibliography source"
2611 );
2612
2613 std::fs::remove_dir_all(&dir).ok();
2614 }
2615
2616 #[test]
2617 fn missing_bibliography_path_emits_mos0040() {
2618 let r = lower("#bibliography()\n", &PathBuf::from("/tmp/no-such.mos"));
2621 assert!(
2622 r.diagnostics
2623 .iter()
2624 .any(|d| d.def().code() == codes::MOS0040.code()),
2625 "expected MOS0040, got {:?}",
2626 r.diagnostics
2627 );
2628 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Bibliography));
2629 }
2630
2631 #[test]
2632 fn empty_bibliography_path_emits_mos0040() {
2633 let r = lower(
2636 "#bibliography(\"\")\n",
2637 &PathBuf::from("/tmp/whatever/main.mos"),
2638 );
2639 assert!(
2640 r.diagnostics
2641 .iter()
2642 .any(|d| d.def().code() == codes::MOS0040.code()),
2643 "expected MOS0040, got {:?}",
2644 r.diagnostics
2645 );
2646 assert!(
2647 !r.diagnostics
2648 .iter()
2649 .any(|d| d.def().code() == codes::MOS0041.code()),
2650 "empty path must not trip the filesystem warning: {:?}",
2651 r.diagnostics
2652 );
2653 }
2654
2655 #[test]
2656 fn non_string_bibliography_path_emits_type_mismatch_only() {
2657 let r = lower(
2660 "#bibliography(src: 12pt)\n",
2661 &PathBuf::from("/tmp/whatever/main.mos"),
2662 );
2663 assert!(
2664 r.diagnostics
2665 .iter()
2666 .any(|d| d.def().code() == codes::MOS0020.code()),
2667 "expected MOS0020, got {:?}",
2668 r.diagnostics
2669 );
2670 assert!(
2671 !r.diagnostics
2672 .iter()
2673 .any(|d| d.def().code() == codes::MOS0040.code()),
2674 "non-string path must not also emit MOS0040: {:?}",
2675 r.diagnostics
2676 );
2677 assert!(
2678 !r.diagnostics
2679 .iter()
2680 .any(|d| d.def().code() == codes::MOS0041.code()),
2681 "non-string path must not reach filesystem warning: {:?}",
2682 r.diagnostics
2683 );
2684 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Bibliography));
2685 }
2686
2687 #[test]
2688 fn duplicate_bibliography_path_keeps_first_path() {
2689 let dir = unique_temp_dir("duplicate-path");
2693 let first = dir.join("first.bib");
2694 let second = dir.join("second.bib");
2695 std::fs::write(&first, "@book{{first}}\n").unwrap();
2696 std::fs::write(&second, "@book{{second}}\n").unwrap();
2697 let source = dir.join("main.mos");
2698 let source_text = "#bibliography(\"first.bib\", path: \"second.bib\")\n";
2699 std::fs::write(&source, source_text).unwrap();
2700 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2701 let duplicate_path_diagnostics: Vec<&Diagnostic> = r
2702 .diagnostics
2703 .iter()
2704 .filter(|d| d.def().code() == codes::MOS0042.code())
2705 .collect();
2706 assert_eq!(
2707 duplicate_path_diagnostics.len(),
2708 1,
2709 "expected one MOS0042, got {:?}",
2710 r.diagnostics
2711 );
2712 let duplicate = duplicate_path_diagnostics[0];
2713 assert_eq!(
2714 duplicate
2715 .span()
2716 .map(|span| &source_text[span.start()..span.end()]),
2717 Some("\"second.bib\""),
2718 "duplicate path diagnostic should point at the later path value"
2719 );
2720 let node = r
2721 .document
2722 .nodes()
2723 .find(|n| n.kind == NodeKind::Bibliography)
2724 .expect("Bibliography node");
2725 assert_eq!(
2726 node.attributes.get("src"),
2727 Some(&AttrValue::Str("first.bib".to_owned()))
2728 );
2729 assert_eq!(
2730 node.attributes.get("resolved_path"),
2731 Some(&AttrValue::Str(first.to_string_lossy().into_owned()))
2732 );
2733 std::fs::remove_dir_all(&dir).ok();
2734 }
2735
2736 #[test]
2737 fn missing_bibliography_source_warns_mos0041_but_keeps_node() {
2738 let dir = unique_temp_dir("absent");
2742 let source = dir.join("main.mos");
2743 std::fs::write(&source, "#bibliography(\"nope.bib\")\n").unwrap();
2744 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2745 assert!(
2746 !r.has_errors(),
2747 "a missing source is a warning, not an error: {:?}",
2748 r.diagnostics
2749 );
2750 assert!(
2751 r.diagnostics
2752 .iter()
2753 .any(|d| d.def().code() == codes::MOS0041.code()),
2754 "expected MOS0041, got {:?}",
2755 r.diagnostics
2756 );
2757 let node = r
2758 .document
2759 .nodes()
2760 .find(|n| n.kind == NodeKind::Bibliography)
2761 .expect("Bibliography node still emitted on a missing source");
2762 assert_eq!(
2763 node.attributes.get("resolved_path"),
2764 Some(&AttrValue::Str(
2765 dir.join("nope.bib").to_string_lossy().into_owned()
2766 ))
2767 );
2768 std::fs::remove_dir_all(&dir).ok();
2769 }
2770
2771 #[test]
2772 fn unknown_bibliography_arg_emits_mos0015() {
2773 let dir = unique_temp_dir("unknownarg");
2777 std::fs::write(dir.join("refs.bib"), "@book{a}\n").unwrap();
2778 let source = dir.join("main.mos");
2779 std::fs::write(&source, "#bibliography(\"refs.bib\", style: \"ieee\")\n").unwrap();
2780 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2781 assert!(
2782 r.diagnostics
2783 .iter()
2784 .any(|d| d.def().code() == codes::MOS0015.code()),
2785 "expected MOS0015, got {:?}",
2786 r.diagnostics
2787 );
2788 std::fs::remove_dir_all(&dir).ok();
2789 }
2790
2791 fn find_unknown_arg_diagnostic(diagnostics: &[Diagnostic]) -> &Diagnostic {
2794 diagnostics
2795 .iter()
2796 .find(|d| d.def().code() == codes::MOS0015.code())
2797 .expect("expected an MOS0015 unknown-argument diagnostic")
2798 }
2799
2800 #[test]
2801 fn image_unknown_arg_close_typo_suggests_key() {
2802 let src = "#image(\"x.png\", wdith: 100pt)\n";
2805 let r = lower(src, &PathBuf::from("test.mos"));
2806 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2807 let suggestions = d.suggestions();
2808 assert_eq!(
2809 suggestions.len(),
2810 1,
2811 "expected one nearest-key suggestion, got {suggestions:?}"
2812 );
2813 assert_eq!(suggestions[0].replacement, "width");
2814 assert_eq!(
2815 &src[suggestions[0].span.start()..suggestions[0].span.end()],
2816 "wdith",
2817 "fix must replace only the key token"
2818 );
2819 }
2820
2821 #[test]
2822 fn image_unknown_arg_far_name_has_no_suggestion() {
2823 let r = lower("#image(\"x.png\", zzz: 1)\n", &PathBuf::from("test.mos"));
2826 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2827 assert!(
2828 d.suggestions().is_empty(),
2829 "an unrelated key must not be guessed, got {:?}",
2830 d.suggestions()
2831 );
2832 }
2833
2834 #[test]
2835 fn figure_unknown_arg_close_typo_suggests_key() {
2836 let src = "#figure(image: \"x.png\", captoin: \"hi\")\n";
2837 let r = lower(src, &PathBuf::from("test.mos"));
2838 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2839 let suggestions = d.suggestions();
2840 assert_eq!(
2841 suggestions.len(),
2842 1,
2843 "expected one nearest-key suggestion, got {suggestions:?}"
2844 );
2845 assert_eq!(suggestions[0].replacement, "caption");
2846 assert_eq!(
2847 &src[suggestions[0].span.start()..suggestions[0].span.end()],
2848 "captoin",
2849 "fix must replace only the key token"
2850 );
2851 }
2852
2853 #[test]
2854 fn bibliography_unknown_arg_close_typo_suggests_key() {
2855 let src = "#bibliography(\"refs.bib\", pth: \"x\")\n";
2856 let r = lower(src, &PathBuf::from("test.mos"));
2857 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2858 let suggestions = d.suggestions();
2859 assert_eq!(
2860 suggestions.len(),
2861 1,
2862 "expected one nearest-key suggestion, got {suggestions:?}"
2863 );
2864 assert_eq!(suggestions[0].replacement, "path");
2865 assert_eq!(
2866 &src[suggestions[0].span.start()..suggestions[0].span.end()],
2867 "pth",
2868 "fix must replace only the key token"
2869 );
2870 }
2871}