1#![doc(
10 html_logo_url = "https://mosaiclang.dev/assets/A4.svg",
11 html_favicon_url = "https://mosaiclang.dev/assets/A4.svg"
12)]
13#[doc(hidden)]
14pub mod bibliography;
15#[doc(hidden)]
16pub mod image;
17#[doc(hidden)]
18pub mod image_lower;
19#[doc(hidden)]
20pub mod inline;
21#[doc(hidden)]
22pub mod list;
23#[doc(hidden)]
24pub mod pageref;
25#[doc(hidden)]
26pub mod resolve;
27#[doc(hidden)]
28pub mod set;
29#[doc(hidden)]
30pub mod set_schema;
31mod suggest;
32
33use std::collections::BTreeMap;
34
35use mos_core::{
36 AttrMap, AttrValue, CollectingSink, Diagnostic, Document, NodeId, NodeKind, NodeSpec, Severity,
37 SourceSpan,
38};
39use mos_parse::{DirectiveKind, Item, RawBlockKind, SyntaxTree};
40
41pub use pageref::{PageFixpointOutcome, resolve_page_reference_fixpoint, resolve_page_references};
42pub use resolve::resolve;
43
44use bibliography::{lower_bibliography_directive, resolve_citations};
45use image_lower::{lower_figure_directive, lower_image_directive};
46use inline::lower_inlines;
47use list::lower as lower_list;
48use set::lower_set_directive;
49
50const LABEL_SPAN_START_ATTR: &str = "label_span.start";
51const LABEL_SPAN_END_ATTR: &str = "label_span.end";
52pub const DOC_ATTR: &str = "doc";
55
56fn insert_label_attributes(attributes: &mut AttrMap, label: &str, label_span: Option<&SourceSpan>) {
57 attributes.insert("label".to_owned(), AttrValue::Str(label.to_owned()));
58 let Some(span) = label_span else {
59 return;
60 };
61 let (Ok(start), Ok(end)) = (i64::try_from(span.start()), i64::try_from(span.end())) else {
62 return;
66 };
67 attributes.insert(LABEL_SPAN_START_ATTR.to_owned(), AttrValue::Int(start));
68 attributes.insert(LABEL_SPAN_END_ATTR.to_owned(), AttrValue::Int(end));
69}
70
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub struct DocumentMetadata {
92 pub title: Option<String>,
93 pub author: Option<String>,
94 pub language: Option<String>,
95}
96
97#[derive(Debug)]
120pub struct LowerResult {
121 pub document: Document,
122 pub diagnostics: Vec<Diagnostic>,
123 pub metadata: DocumentMetadata,
124 pub reads_external_resources: bool,
133}
134
135impl LowerResult {
136 #[must_use]
148 pub fn has_errors(&self) -> bool {
149 self.diagnostics
150 .iter()
151 .any(|d| d.severity() == Severity::Error)
152 }
153}
154
155#[derive(Copy, Clone, Default, Debug)]
167pub struct Evaluator;
168
169impl Evaluator {
170 #[must_use]
182 pub const fn new() -> Self {
183 Self
184 }
185
186 #[must_use]
209 pub fn evaluate(tree: &SyntaxTree) -> LowerResult {
210 let mut state = EvaluationState::new(tree);
211 for item in &tree.items {
212 state.lower_item(item, &tree.file);
213 }
214 state.finish()
215 }
216}
217
218struct EvaluationState {
219 document: Document,
220 diagnostics: Vec<Diagnostic>,
221 metadata: DocumentMetadata,
222 current_text_size_pt: f64,
223 reads_external_resources: bool,
224 pending_doc: Option<String>,
229}
230
231impl EvaluationState {
232 fn new(tree: &SyntaxTree) -> Self {
233 Self {
234 document: Document::new(tree.file.clone()),
235 diagnostics: Vec::new(),
236 metadata: DocumentMetadata::default(),
237 current_text_size_pt: 11.0,
238 reads_external_resources: false,
239 pending_doc: None,
240 }
241 }
242
243 fn finish(self) -> LowerResult {
244 LowerResult {
245 document: self.document,
246 diagnostics: self.diagnostics,
247 metadata: self.metadata,
248 reads_external_resources: self.reads_external_resources,
249 }
250 }
251
252 fn lower_item(&mut self, item: &Item, source_file: &std::path::Path) {
253 match item {
257 Item::DocComment { text, .. } => {
258 self.pending_doc = Some(text.clone());
259 }
260 Item::Heading {
261 level,
262 inlines,
263 label,
264 label_span,
265 span,
266 } => {
267 let doc = self.pending_doc.take();
268 self.lower_heading(
269 *level,
270 inlines,
271 label.as_deref(),
272 label_span.as_ref(),
273 span,
274 doc,
275 );
276 }
277 Item::Paragraph {
278 inlines,
279 label,
280 label_span,
281 span,
282 } => {
283 let doc = self.pending_doc.take();
284 self.lower_paragraph(inlines, label.as_deref(), label_span.as_ref(), span, doc);
285 }
286 Item::List {
287 ordered,
288 items,
289 span,
290 } => {
291 self.pending_doc = None;
292 let root = self.document.root;
293 lower_list(&mut self.document, root, *ordered, items, span);
294 }
295 Item::RawBlock {
296 kind,
297 text,
298 label,
299 label_span,
300 span,
301 ..
302 } => {
303 self.pending_doc = None;
304 let root = self.document.root;
305 lower_raw_block(
306 &mut self.document,
307 root,
308 *kind,
309 text,
310 label.as_deref(),
311 label_span.as_ref(),
312 span,
313 );
314 }
315 Item::Set {
316 kind,
317 name,
318 args,
319 span,
320 } => {
321 self.pending_doc = None;
322 self.lower_directive(*kind, name, args, span, source_file);
323 }
324 }
325 }
326
327 fn lower_heading(
328 &mut self,
329 level: u8,
330 inlines: &[mos_parse::Inline],
331 label: Option<&str>,
332 label_span: Option<&SourceSpan>,
333 span: &SourceSpan,
334 doc: Option<String>,
335 ) {
336 let mut attributes: AttrMap = BTreeMap::new();
337 attributes.insert("level".to_owned(), AttrValue::Int(i64::from(level)));
338 if let Some(id) = label {
339 insert_label_attributes(&mut attributes, id, label_span);
340 }
341 if let Some(doc) = doc {
342 attributes.insert(DOC_ATTR.to_owned(), AttrValue::Str(doc));
343 }
344 let heading = self.document.alloc_child(
345 self.document.root,
346 NodeSpec::new(NodeKind::Section, span.clone()).with_attributes(attributes),
347 );
348 lower_inlines(&mut self.document, heading, inlines);
349 }
350
351 fn lower_paragraph(
352 &mut self,
353 inlines: &[mos_parse::Inline],
354 label: Option<&str>,
355 label_span: Option<&SourceSpan>,
356 span: &SourceSpan,
357 doc: Option<String>,
358 ) {
359 let mut attributes: AttrMap = BTreeMap::new();
360 if let Some(id) = label {
361 insert_label_attributes(&mut attributes, id, label_span);
362 }
363 if let Some(doc) = doc {
364 attributes.insert(DOC_ATTR.to_owned(), AttrValue::Str(doc));
365 }
366 let para = self.document.alloc_child(
367 self.document.root,
368 NodeSpec::new(NodeKind::Paragraph, span.clone()).with_attributes(attributes),
369 );
370 lower_inlines(&mut self.document, para, inlines);
371 }
372
373 fn lower_directive(
374 &mut self,
375 kind: DirectiveKind,
376 name: &str,
377 args: &[mos_parse::SetArg],
378 span: &SourceSpan,
379 source_file: &std::path::Path,
380 ) {
381 match kind {
382 DirectiveKind::Image => {
383 let root = self.document.root;
384 lower_image_directive(
385 &mut self.document,
386 root,
387 args,
388 span,
389 source_file,
390 self.current_text_size_pt,
391 &mut self.diagnostics,
392 );
393 self.reads_external_resources = true;
394 }
395 DirectiveKind::Figure => {
396 let root = self.document.root;
397 lower_figure_directive(
398 &mut self.document,
399 root,
400 args,
401 span,
402 source_file,
403 self.current_text_size_pt,
404 &mut self.diagnostics,
405 );
406 self.reads_external_resources = true;
407 }
408 DirectiveKind::Bibliography => {
409 let root = self.document.root;
410 lower_bibliography_directive(
411 &mut self.document,
412 root,
413 args,
414 span,
415 source_file,
416 &mut self.diagnostics,
417 );
418 self.reads_external_resources = true;
419 }
420 DirectiveKind::Set => {
421 let root = self.document.root;
422 lower_set_directive(
423 &mut self.document,
424 root,
425 name,
426 args,
427 span,
428 &mut self.metadata,
429 &mut self.current_text_size_pt,
430 &mut self.diagnostics,
431 );
432 }
433 }
434 }
435}
436
437fn lower_raw_block(
438 document: &mut Document,
439 root: NodeId,
440 kind: RawBlockKind,
441 text: &str,
442 label: Option<&str>,
443 label_span: Option<&SourceSpan>,
444 span: &SourceSpan,
445) {
446 let mut attributes: AttrMap = BTreeMap::new();
447 attributes.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
448 if let Some(id) = label {
449 insert_label_attributes(&mut attributes, id, label_span);
450 }
451 attributes.insert(
452 "raw.kind".to_owned(),
453 AttrValue::Str(
454 match kind {
455 RawBlockKind::Pre => "pre",
456 RawBlockKind::Code => "code",
457 }
458 .to_owned(),
459 ),
460 );
461 document.alloc_child(
462 root,
463 NodeSpec::new(NodeKind::Raw, span.clone()).with_attributes(attributes),
464 );
465}
466
467#[must_use]
481pub fn lower(src: &str, file: &std::path::Path) -> LowerResult {
482 let mut sink = CollectingSink::new();
483 let tree = match mos_parse::parse(src, file, &mut sink) {
484 Ok(tree) => tree,
485 Err(mos_core::DiagnosticAbort) => {
488 return LowerResult {
489 document: Document::new(file.to_path_buf()),
490 diagnostics: sink.into_diagnostics(),
491 metadata: DocumentMetadata::default(),
492 reads_external_resources: false,
494 };
495 }
496 };
497 let mut diagnostics = sink.into_diagnostics();
498 let mut lowered = lower_tree(&tree);
499 diagnostics.append(&mut lowered.diagnostics);
500 LowerResult {
501 document: lowered.document,
502 diagnostics,
503 metadata: lowered.metadata,
504 reads_external_resources: lowered.reads_external_resources,
505 }
506}
507
508#[must_use]
532pub fn lower_tree(tree: &SyntaxTree) -> LowerResult {
533 let mut lowered = Evaluator::evaluate(tree);
534 let mut diagnostics = std::mem::take(&mut lowered.diagnostics);
535 let bib_keys = resolve_citations(&mut lowered.document, &mut diagnostics);
536 diagnostics.extend(resolve(&mut lowered.document, &bib_keys));
537 LowerResult {
538 document: lowered.document,
539 diagnostics,
540 metadata: lowered.metadata,
541 reads_external_resources: lowered.reads_external_resources,
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 #![allow(
548 clippy::unwrap_used,
549 clippy::expect_used,
550 clippy::panic,
551 reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
552 )]
553 use std::path::PathBuf;
554
555 use mos_core::{NodeKind, codes};
556
557 use super::*;
558
559 #[cfg(target_pointer_width = "64")]
560 #[test]
561 fn label_attributes_omit_unrepresentable_span_bounds() {
562 let too_large = usize::try_from(i64::MAX).unwrap().saturating_add(1);
563 let span = SourceSpan::new(
564 PathBuf::from("test.mos"),
565 too_large,
566 too_large.saturating_add(1),
567 );
568 let mut attributes = AttrMap::new();
569
570 insert_label_attributes(&mut attributes, "huge", Some(&span));
571
572 assert_eq!(
573 attributes.get("label"),
574 Some(&AttrValue::Str("huge".to_owned()))
575 );
576 assert!(!attributes.contains_key(LABEL_SPAN_START_ATTR));
577 assert!(!attributes.contains_key(LABEL_SPAN_END_ATTR));
578 }
579
580 #[test]
581 fn doc_comment_attaches_to_following_heading() {
582 let file = PathBuf::from("test.mos");
583 let r = lower("/** Section doc. */\n= Intro <intro>\n", &file);
584 let section = r
585 .document
586 .nodes()
587 .find(|n| n.kind == NodeKind::Section)
588 .expect("a section node");
589 assert_eq!(
590 section.attributes.get(DOC_ATTR),
591 Some(&AttrValue::Str("Section doc.".to_owned())),
592 );
593 }
594
595 #[test]
596 fn doc_comment_attaches_to_following_paragraph() {
597 let file = PathBuf::from("test.mos");
598 let r = lower("/** Para doc. */\nBody text.\n", &file);
599 let para = r
600 .document
601 .nodes()
602 .find(|n| n.kind == NodeKind::Paragraph)
603 .expect("a paragraph node");
604 assert_eq!(
605 para.attributes.get(DOC_ATTR),
606 Some(&AttrValue::Str("Para doc.".to_owned())),
607 );
608 }
609
610 #[test]
611 fn doc_comment_does_not_leak_past_a_non_documentable_block() {
612 let file = PathBuf::from("test.mos");
615 let r = lower("/** stray */\n#set document(title: \"x\")\n\n= H\n", &file);
616 let section = r
617 .document
618 .nodes()
619 .find(|n| n.kind == NodeKind::Section)
620 .expect("a section node");
621 assert!(
622 !section.attributes.contains_key(DOC_ATTR),
623 "doc must not leak past #set"
624 );
625 }
626
627 #[test]
628 fn doc_comment_does_not_attach_to_or_leak_past_a_list() {
629 let file = PathBuf::from("test.mos");
630 let r = lower("/** stray */\n- item\n\n= H\n", &file);
631 let list = r
632 .document
633 .nodes()
634 .find(|n| n.kind == NodeKind::List)
635 .expect("a list node");
636 assert!(
637 !list.attributes.contains_key(DOC_ATTR),
638 "doc must not attach to a list"
639 );
640 let section = r
641 .document
642 .nodes()
643 .find(|n| n.kind == NodeKind::Section)
644 .expect("a section node");
645 assert!(
646 !section.attributes.contains_key(DOC_ATTR),
647 "doc must not leak past a list"
648 );
649 }
650
651 #[test]
652 fn doc_comment_does_not_attach_to_or_leak_past_a_raw_block() {
653 let file = PathBuf::from("test.mos");
654 let r = lower("/** stray */\n#code[[x]]\n\n= H\n", &file);
655 let raw = r
656 .document
657 .nodes()
658 .find(|n| n.kind == NodeKind::Raw)
659 .expect("a raw node");
660 assert!(
661 !raw.attributes.contains_key(DOC_ATTR),
662 "doc must not attach to a raw block"
663 );
664 let section = r
665 .document
666 .nodes()
667 .find(|n| n.kind == NodeKind::Section)
668 .expect("a section node");
669 assert!(
670 !section.attributes.contains_key(DOC_ATTR),
671 "doc must not leak past a raw block"
672 );
673 }
674
675 #[test]
676 fn plain_block_comment_attaches_no_doc() {
677 let file = PathBuf::from("test.mos");
678 let r = lower("/* not doc */\n= H\n", &file);
679 let section = r
680 .document
681 .nodes()
682 .find(|n| n.kind == NodeKind::Section)
683 .expect("a section node");
684 assert!(!section.attributes.contains_key(DOC_ATTR));
685 }
686
687 #[test]
688 fn reads_external_resources_flags_filesystem_directives() {
689 let file = PathBuf::from("test.mos");
690 assert!(
692 !lower("= Title <t>\n\nSee @t\n", &file).reads_external_resources,
693 "a source with no filesystem directives lowers purely"
694 );
695 assert!(
699 lower("#image(\"missing.png\")\n", &file).reads_external_resources,
700 "`#image` reads an external file"
701 );
702 assert!(
703 lower("#figure(image: \"missing.png\")\n", &file).reads_external_resources,
704 "`#figure` loads an external image"
705 );
706 assert!(
707 lower("#bibliography(\"missing.bib\")\n", &file).reads_external_resources,
708 "`#bibliography` reads an external source file"
709 );
710 }
711
712 #[test]
713 fn lowerer_stamps_paired_label_span_covering_the_label_token() {
714 let src = "= Intro <intro>\n";
722 let r = lower(src, &PathBuf::from("test.mos"));
723 assert!(!r.has_errors(), "{:?}", r.diagnostics);
724
725 let section = r
726 .document
727 .nodes()
728 .find(|node| {
729 node.kind == NodeKind::Section
730 && node.attributes.get("label") == Some(&AttrValue::Str("intro".to_owned()))
731 })
732 .expect("a Section node carrying label `intro`");
733
734 let (Some(&AttrValue::Int(start)), Some(&AttrValue::Int(end))) = (
735 section.attributes.get(LABEL_SPAN_START_ATTR),
736 section.attributes.get(LABEL_SPAN_END_ATTR),
737 ) else {
738 panic!(
739 "expected paired Int `label_span.*` attrs, got {:?} / {:?}",
740 section.attributes.get(LABEL_SPAN_START_ATTR),
741 section.attributes.get(LABEL_SPAN_END_ATTR),
742 );
743 };
744
745 let start = usize::try_from(start).expect("label_span.start fits usize");
746 let end = usize::try_from(end).expect("label_span.end fits usize");
747 assert!(
748 start <= end,
749 "label span start {start} must not exceed end {end}"
750 );
751 assert_eq!(
752 src.get(start..end),
753 Some("intro"),
754 "label span must cover exactly the `intro` token, not the whole heading"
755 );
756 }
757
758 #[test]
759 fn reference_nodes_carry_stamped_label_span() {
760 let src = "= Intro <intro>\n\nsee @intro and @page(intro)\n";
765 let r = lower(src, &PathBuf::from("test.mos"));
766 assert!(!r.has_errors(), "{:?}", r.diagnostics);
767
768 for kind in [NodeKind::Reference, NodeKind::PageReference] {
769 let node = r
770 .document
771 .nodes()
772 .find(|node| node.kind == kind)
773 .unwrap_or_else(|| panic!("expected a {kind:?} node"));
774 let (Some(&AttrValue::Int(start)), Some(&AttrValue::Int(end))) = (
775 node.attributes.get(LABEL_SPAN_START_ATTR),
776 node.attributes.get(LABEL_SPAN_END_ATTR),
777 ) else {
778 panic!(
779 "{kind:?} node missing paired `label_span.*`: {:?}",
780 node.attributes
781 );
782 };
783 let start = usize::try_from(start).expect("label_span.start fits usize");
784 let end = usize::try_from(end).expect("label_span.end fits usize");
785 assert_eq!(
786 src.get(start..end),
787 Some("intro"),
788 "{kind:?} label span must cover exactly the `intro` identifier"
789 );
790 }
791 }
792
793 #[test]
794 fn lowers_heading_and_paragraph() {
795 let r = lower(
796 "= Hello\n\nbody *italic* text\n",
797 &PathBuf::from("test.mos"),
798 );
799 assert!(!r.has_errors());
800 assert_eq!(r.document.len(), 1 + 2 + 1 + 3);
803
804 let kinds: Vec<NodeKind> = r.document.nodes().map(|n| n.kind).collect();
805 assert_eq!(kinds[0], NodeKind::Document);
806 assert!(kinds.contains(&NodeKind::Section));
807 assert!(kinds.contains(&NodeKind::Paragraph));
808 assert!(kinds.contains(&NodeKind::Emphasis));
809 }
810
811 #[test]
812 fn lowers_nested_bold_italic_inline() {
813 let r = lower("***both***\n", &PathBuf::from("test.mos"));
814 assert!(!r.has_errors(), "{:?}", r.diagnostics);
815 assert!(
816 r.document.nodes().any(|n| n.kind == NodeKind::BoldItalic),
817 "expected bold-italic node in {:?}",
818 r.document.nodes().map(|n| n.kind).collect::<Vec<_>>()
819 );
820 }
821
822 #[test]
823 fn root_owns_top_level_items() {
824 let r = lower("= A\n\n= B\n\npara\n", &PathBuf::from("test.mos"));
825 let root = r.document.get(r.document.root).unwrap();
826 assert_eq!(root.children.len(), 3);
827 }
828
829 #[test]
830 fn hard_break_lowers_to_hardbreak_node_without_text_attr() {
831 let r = lower("foo\\\\bar\n", &PathBuf::from("test.mos"));
832 assert!(!r.has_errors(), "{:?}", r.diagnostics);
833
834 let root = r.document.get(r.document.root).unwrap();
837 let paragraph_id = *root.children.first().unwrap();
838 let paragraph = r.document.get(paragraph_id).unwrap();
839 let inline_kinds: Vec<NodeKind> = paragraph
840 .children
841 .iter()
842 .filter_map(|id| r.document.get(*id).map(|n| n.kind))
843 .collect();
844 assert_eq!(
845 inline_kinds,
846 vec![NodeKind::Text, NodeKind::HardBreak, NodeKind::Text],
847 "got {inline_kinds:?}"
848 );
849
850 let hardbreak_id = paragraph.children[1];
853 let hardbreak = r.document.get(hardbreak_id).unwrap();
854 assert!(
855 hardbreak.attributes.is_empty(),
856 "expected empty attribute map on HardBreak, got {:?}",
857 hardbreak.attributes
858 );
859 }
860
861 fn write_tiny_png(name: &str) -> PathBuf {
867 let dir = std::env::temp_dir().join(format!(
868 "mos-eval-image-{}-{}",
869 name,
870 std::time::SystemTime::now()
871 .duration_since(std::time::UNIX_EPOCH)
872 .map_or(0, |d| d.as_nanos())
873 ));
874 std::fs::create_dir_all(&dir).unwrap();
875 let path = dir.join(name);
876 let mut buf = ::image::RgbaImage::new(3, 2);
877 for x in 0_u32..3 {
878 for y in 0_u32..2 {
879 let r = u8::try_from(x * 80).unwrap_or(0);
880 let g = u8::try_from(y * 120).unwrap_or(0);
881 buf.put_pixel(x, y, ::image::Rgba([r, g, 200, 255]));
882 }
883 }
884 buf.save(&path).unwrap();
885 path
886 }
887
888 #[test]
889 fn image_directive_attaches_decoded_pixels() {
890 let png_path = write_tiny_png("tiny.png");
891 let source = png_path.parent().unwrap().join("main.mos");
892 std::fs::write(&source, "#image(\"tiny.png\")\n").unwrap();
893 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
894 assert!(!r.has_errors(), "{:?}", r.diagnostics);
895 let image_node = r
896 .document
897 .nodes()
898 .find(|n| n.kind == NodeKind::Image)
899 .expect("Image node");
900 assert_eq!(
901 image_node.attributes.get("src"),
902 Some(&AttrValue::Str("tiny.png".to_owned()))
903 );
904 assert_eq!(
905 image_node.attributes.get("pixel_width"),
906 Some(&AttrValue::Int(3))
907 );
908 assert_eq!(
909 image_node.attributes.get("pixel_height"),
910 Some(&AttrValue::Int(2))
911 );
912 match image_node.attributes.get("pixels") {
913 Some(AttrValue::Bytes(b)) => assert_eq!(b.len(), 3 * 3 * 2),
914 other => panic!("expected pixel bytes, got {other:?}"),
915 }
916 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
917 }
918
919 #[test]
920 fn image_directive_records_explicit_dimensions() {
921 let png_path = write_tiny_png("dims.png");
922 let source = png_path.parent().unwrap().join("main.mos");
923 std::fs::write(
924 &source,
925 "#image(\"dims.png\", width: 100pt, height: 60pt, alt: \"a tiny image\")\n",
926 )
927 .unwrap();
928 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
929 assert!(!r.has_errors(), "{:?}", r.diagnostics);
930 let image_node = r
931 .document
932 .nodes()
933 .find(|n| n.kind == NodeKind::Image)
934 .expect("Image node");
935 assert_eq!(
936 image_node.attributes.get("width"),
937 Some(&AttrValue::Length(100.0))
938 );
939 assert_eq!(
940 image_node.attributes.get("height"),
941 Some(&AttrValue::Length(60.0))
942 );
943 assert_eq!(
944 image_node.attributes.get("alt"),
945 Some(&AttrValue::Str("a tiny image".to_owned()))
946 );
947 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
948 }
949
950 #[test]
951 fn image_em_width_resolves_against_current_text_size() {
952 let png_path = write_tiny_png("em.png");
957 let dir = png_path.parent().unwrap();
958 let source = dir.join("main.mos");
959 std::fs::write(
960 &source,
961 "#set text(size: 20pt)\n#image(\"em.png\", width: 2em)\n",
962 )
963 .unwrap();
964 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
965 assert!(!r.has_errors(), "{:?}", r.diagnostics);
966 let image_node = r
967 .document
968 .nodes()
969 .find(|n| n.kind == NodeKind::Image)
970 .expect("Image node");
971 match image_node.attributes.get("width") {
972 Some(AttrValue::Length(pt)) => assert!(
973 (pt - 40.0).abs() < 0.01,
974 "width = {pt}pt, expected 40pt (2em at 20pt)"
975 ),
976 other => panic!("expected width Length, got {other:?}"),
977 }
978 std::fs::remove_dir_all(dir).ok();
979 }
980
981 #[test]
982 fn missing_image_path_emits_mos0037() {
983 let r = lower("#image()\n", &PathBuf::from("/tmp/no-such.mos"));
984 assert!(
985 r.diagnostics
986 .iter()
987 .any(|d| d.def().code() == codes::MOS0037.code()),
988 "expected MOS0037, got {:?}",
989 r.diagnostics
990 );
991 }
992
993 #[test]
994 fn unreadable_image_emits_mos0012() {
995 let r = lower(
996 "#image(\"does-not-exist.png\")\n",
997 &PathBuf::from("/tmp/no-such-dir/main.mos"),
998 );
999 assert!(
1000 r.diagnostics
1001 .iter()
1002 .any(|d| d.def().code() == codes::MOS0012.code()),
1003 "expected MOS0012, got {:?}",
1004 r.diagnostics
1005 );
1006 }
1007
1008 #[test]
1009 fn empty_image_path_emits_mos0037_not_io_error() {
1010 let r = lower("#image(\"\")\n", &PathBuf::from("/tmp/whatever/main.mos"));
1015 assert!(
1016 r.diagnostics
1017 .iter()
1018 .any(|d| d.def().code() == codes::MOS0037.code()),
1019 "expected MOS0037, got {:?}",
1020 r.diagnostics
1021 );
1022 assert!(
1024 !r.diagnostics.iter().any(|d| {
1025 d.def().code() == codes::MOS0012.code() || d.def().code() == codes::MOS0029.code()
1026 }),
1027 "unexpected I/O diagnostic: {:?}",
1028 r.diagnostics
1029 );
1030 }
1031
1032 #[test]
1033 fn non_positive_image_width_emits_mos0020() {
1034 for src in [
1038 "#image(\"x.png\", width: 0pt)\n",
1039 "#image(\"x.png\", width: -10pt)\n",
1040 "#image(\"x.png\", width: 0)\n",
1041 "#image(\"x.png\", width: -1)\n",
1042 ] {
1043 let r = lower(src, &PathBuf::from("/tmp/whatever/main.mos"));
1044 assert!(
1045 r.diagnostics
1046 .iter()
1047 .any(|d| d.def().code() == codes::MOS0020.code()),
1048 "expected MOS0020 for `{src}`, got {:?}",
1049 r.diagnostics
1050 );
1051 }
1052 }
1053
1054 #[test]
1055 fn non_positive_image_height_emits_mos0020() {
1056 for src in [
1057 "#image(\"x.png\", height: 0pt)\n",
1058 "#image(\"x.png\", height: -1mm)\n",
1059 ] {
1060 let r = lower(src, &PathBuf::from("/tmp/whatever/main.mos"));
1061 assert!(
1062 r.diagnostics
1063 .iter()
1064 .any(|d| d.def().code() == codes::MOS0020.code()),
1065 "expected MOS0020 for `{src}`, got {:?}",
1066 r.diagnostics
1067 );
1068 }
1069 }
1070
1071 #[test]
1072 fn undecodable_image_emits_mos0029() {
1073 let dir = std::env::temp_dir().join(format!(
1074 "mos-eval-bad-{}",
1075 std::time::SystemTime::now()
1076 .duration_since(std::time::UNIX_EPOCH)
1077 .map_or(0, |d| d.as_nanos())
1078 ));
1079 std::fs::create_dir_all(&dir).unwrap();
1080 let png = dir.join("bad.png");
1081 std::fs::write(&png, b"not really a PNG").unwrap();
1082 let source = dir.join("main.mos");
1083 std::fs::write(&source, "#image(\"bad.png\")\n").unwrap();
1084 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1085 assert!(
1086 r.diagnostics
1087 .iter()
1088 .any(|d| d.def().code() == codes::MOS0029.code()),
1089 "expected MOS0029, got {:?}",
1090 r.diagnostics
1091 );
1092 std::fs::remove_dir_all(&dir).ok();
1093 }
1094
1095 #[test]
1096 fn figure_directive_creates_figure_with_image_and_caption() {
1097 let png_path = write_tiny_png("fig.png");
1098 let source = png_path.parent().unwrap().join("main.mos");
1099 std::fs::write(
1100 &source,
1101 "#figure(image: \"fig.png\", caption: \"A tiny picture.\")\n",
1102 )
1103 .unwrap();
1104 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1105 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1106 let figure = r
1107 .document
1108 .nodes()
1109 .find(|n| n.kind == NodeKind::Figure)
1110 .expect("Figure node");
1111 assert_eq!(figure.children.len(), 2);
1112 let img = r.document.get(figure.children[0]).unwrap();
1113 assert_eq!(img.kind, NodeKind::Image);
1114 let caption = r.document.get(figure.children[1]).unwrap();
1115 assert_eq!(caption.kind, NodeKind::Paragraph);
1116 assert_eq!(
1117 caption.attributes.get("role"),
1118 Some(&AttrValue::Str("caption".to_owned()))
1119 );
1120 let caption_text = r.document.get(caption.children[0]).unwrap();
1123 assert_eq!(
1124 caption_text.attributes.get("text"),
1125 Some(&AttrValue::Str(
1126 "Figure\u{00A0}1: A tiny picture.".to_owned()
1127 ))
1128 );
1129 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1130 }
1131
1132 #[test]
1133 fn figure_with_missing_image_does_not_leak_empty_node() {
1134 let r = lower(
1141 "#figure(image: \"does-not-exist.png\", caption: \"missing\")\n",
1142 &PathBuf::from("/tmp/no-such-dir/main.mos"),
1143 );
1144 assert!(
1145 r.diagnostics
1146 .iter()
1147 .any(|d| d.def().code() == codes::MOS0012.code())
1148 );
1149 assert!(
1150 !r.document.nodes().any(|n| n.kind == NodeKind::Figure),
1151 "Figure node leaked after image load failure",
1152 );
1153 }
1154
1155 #[test]
1156 fn figure_label_reference_resolves_to_figure_number() {
1157 let png_path = write_tiny_png("ref-fig.png");
1163 let dir = png_path.parent().unwrap();
1164 let source = dir.join("main.mos");
1165 std::fs::write(
1166 &source,
1167 "#figure(image: \"ref-fig.png\", caption: \"A plot.\", label: \"fig:plot\")\n\nSee @fig:plot here.\n",
1168 )
1169 .unwrap();
1170 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1171 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1172
1173 let figure = r
1174 .document
1175 .nodes()
1176 .find(|n| n.kind == NodeKind::Figure)
1177 .expect("Figure node");
1178 assert_eq!(
1179 figure.attributes.get("number"),
1180 Some(&AttrValue::Str("1".to_owned())),
1181 "the lowered figure is numbered in document order"
1182 );
1183 assert_eq!(
1184 figure.attributes.get("label"),
1185 Some(&AttrValue::Str("fig:plot".to_owned())),
1186 "the `label:` argument lands on the Figure node"
1187 );
1188
1189 let caption_text = figure
1191 .children
1192 .iter()
1193 .filter_map(|c| r.document.get(*c))
1194 .find(|c| {
1195 matches!(c.attributes.get("role"), Some(AttrValue::Str(role)) if role == "caption")
1196 })
1197 .and_then(|caption| caption.children.first())
1198 .and_then(|text_id| r.document.get(*text_id))
1199 .and_then(|text| text.attributes.get("text"));
1200 assert_eq!(
1201 caption_text,
1202 Some(&AttrValue::Str("Figure\u{00A0}1: A plot.".to_owned())),
1203 "the caption is prefixed with the `Figure N: ` label"
1204 );
1205
1206 let reference = r
1207 .document
1208 .nodes()
1209 .find(|n| n.kind == NodeKind::Reference)
1210 .expect("Reference node");
1211 assert_eq!(
1212 reference.attributes.get("text"),
1213 Some(&AttrValue::Str("Figure\u{00A0}1".to_owned())),
1214 "the `@fig:plot` reference resolves to kind-aware figure text"
1215 );
1216
1217 std::fs::remove_dir_all(dir).ok();
1218 }
1219
1220 #[test]
1221 fn page_reference_lowers_to_inert_page_reference_node() {
1222 let r = lower(
1230 "= Intro <intro>\n\nSee @page(intro) here.\n",
1231 &PathBuf::from("test.mos"),
1232 );
1233 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1234
1235 let page_ref = r
1236 .document
1237 .nodes()
1238 .find(|n| n.kind == NodeKind::PageReference)
1239 .expect("PageReference node");
1240 assert_eq!(
1241 page_ref.attributes.get("label"),
1242 Some(&AttrValue::Str("intro".to_owned())),
1243 );
1244 assert_eq!(
1245 page_ref.attributes.get("text"),
1246 Some(&AttrValue::Str("?intro?".to_owned())),
1247 "unresolved page references keep a visible placeholder",
1248 );
1249 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1251 }
1252
1253 #[test]
1254 fn undeclared_page_reference_label_emits_mos0033() {
1255 let r = lower("See @page(missing) here.\n", &PathBuf::from("test.mos"));
1258 assert!(
1259 r.diagnostics
1260 .iter()
1261 .any(|d| d.def().code() == codes::MOS0033.code()),
1262 "{:?}",
1263 r.diagnostics
1264 );
1265 let page_ref = r
1267 .document
1268 .nodes()
1269 .find(|n| n.kind == NodeKind::PageReference)
1270 .expect("PageReference node");
1271 assert_eq!(
1272 page_ref.attributes.get("text"),
1273 Some(&AttrValue::Str("?missing?".to_owned())),
1274 );
1275 }
1276
1277 #[test]
1278 fn page_reference_to_a_declared_label_is_not_a_duplicate_declaration() {
1279 let r = lower(
1282 "= Intro <intro>\n\nSee @page(intro) here.\n",
1283 &PathBuf::from("test.mos"),
1284 );
1285 assert!(
1286 !r.diagnostics
1287 .iter()
1288 .any(|d| d.def().code() == codes::MOS0030.code()),
1289 "{:?}",
1290 r.diagnostics
1291 );
1292 }
1293
1294 #[test]
1295 fn citation_lowers_to_citation_node_with_key_and_span() {
1296 let src = "see [@smith2024] here\n";
1303 let r = lower(src, &PathBuf::from("test.mos"));
1304 assert!(
1305 r.diagnostics
1306 .iter()
1307 .any(|d| d.def().code() == codes::MOS0045.code()),
1308 "expected MOS0045 because no bibliography records are declared, got {:?}",
1309 r.diagnostics
1310 );
1311 let citation = r
1312 .document
1313 .nodes()
1314 .find(|n| n.kind == NodeKind::Citation)
1315 .expect("citation node");
1316 assert_eq!(
1317 citation.attributes.get("key"),
1318 Some(&AttrValue::Str("smith2024".to_owned())),
1319 );
1320 assert_eq!(
1321 citation.attributes.get("text"),
1322 Some(&AttrValue::Str("[?smith2024?]".to_owned())),
1323 );
1324 let span_text = &src[citation.span.start()..citation.span.end()];
1325 assert_eq!(span_text, "[@smith2024]");
1326 }
1327
1328 #[test]
1329 fn malformed_citation_does_not_create_citation_node() {
1330 let r = lower("look [@] here\n", &PathBuf::from("test.mos"));
1334 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1335 assert!(
1336 r.diagnostics
1337 .iter()
1338 .any(|d| d.def().code() == codes::MOS0039.code()),
1339 "expected MOS0039, got {:?}",
1340 r.diagnostics,
1341 );
1342 assert!(
1343 !r.document.nodes().any(|n| n.kind == NodeKind::Citation),
1344 "no Citation nodes expected, got {:?}",
1345 r.document.nodes().map(|n| n.kind).collect::<Vec<_>>(),
1346 );
1347 }
1348
1349 #[test]
1350 fn unterminated_citation_does_not_leak_into_reference_resolver() {
1351 let r = lower(
1359 "see [@smith2024 missing close\n",
1360 &PathBuf::from("test.mos"),
1361 );
1362 assert!(
1363 !r.has_errors(),
1364 "no errors expected, got {:?}",
1365 r.diagnostics,
1366 );
1367 assert!(
1368 r.diagnostics
1369 .iter()
1370 .any(|d| d.def().code() == codes::MOS0039.code()),
1371 "expected MOS0039, got {:?}",
1372 r.diagnostics,
1373 );
1374 assert!(
1375 !r.diagnostics
1376 .iter()
1377 .any(|d| d.def().code() == codes::MOS0033.code()),
1378 "malformed citation must not surface as unknown-label MOS0033: {:?}",
1379 r.diagnostics,
1380 );
1381 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Citation));
1382 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1383 }
1384
1385 #[test]
1386 fn deferred_multi_key_citation_does_not_leak_into_reference_resolver() {
1387 let r = lower(
1392 "compare [@smith2024; @jones2025] now\n",
1393 &PathBuf::from("test.mos"),
1394 );
1395 assert!(
1396 !r.has_errors(),
1397 "no errors expected, got {:?}",
1398 r.diagnostics,
1399 );
1400 assert!(
1401 r.diagnostics
1402 .iter()
1403 .any(|d| d.def().code() == codes::MOS0039.code())
1404 );
1405 assert!(
1406 !r.diagnostics
1407 .iter()
1408 .any(|d| d.def().code() == codes::MOS0033.code()),
1409 "multi-key citation must not surface as unknown-label MOS0033: {:?}",
1410 r.diagnostics,
1411 );
1412 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Citation));
1413 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1414 }
1415
1416 #[test]
1417 fn figure_directive_accepts_positional_path() {
1418 let png_path = write_tiny_png("fig_pos.png");
1422 let source = png_path.parent().unwrap().join("main.mos");
1423 std::fs::write(&source, "#figure(\"fig_pos.png\")\n").unwrap();
1424 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1425 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1426 let figure = r
1427 .document
1428 .nodes()
1429 .find(|n| n.kind == NodeKind::Figure)
1430 .expect("Figure node");
1431 assert_eq!(figure.children.len(), 1);
1433 let img = r.document.get(figure.children[0]).unwrap();
1434 assert_eq!(img.kind, NodeKind::Image);
1435 assert_eq!(
1436 img.attributes.get("src"),
1437 Some(&AttrValue::Str("fig_pos.png".to_owned()))
1438 );
1439 std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1440 }
1441
1442 fn unique_temp_dir(name: &str) -> PathBuf {
1446 let dir = std::env::temp_dir().join(format!(
1447 "mos-eval-bib-{}-{}",
1448 name,
1449 std::time::SystemTime::now()
1450 .duration_since(std::time::UNIX_EPOCH)
1451 .map_or(0, |d| d.as_nanos())
1452 ));
1453 std::fs::create_dir_all(&dir).unwrap();
1454 dir
1455 }
1456
1457 #[test]
1458 fn bibliography_directive_preserves_resolved_path() {
1459 let dir = unique_temp_dir("preserve");
1464 let bib = dir.join("refs.bib");
1465 std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1466 let source = dir.join("main.mos");
1467 std::fs::write(&source, "#bibliography(\"refs.bib\")\n").unwrap();
1468 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1469 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1470 let node = r
1471 .document
1472 .nodes()
1473 .find(|n| n.kind == NodeKind::Bibliography)
1474 .expect("Bibliography node");
1475 assert_eq!(
1476 node.attributes.get("src"),
1477 Some(&AttrValue::Str("refs.bib".to_owned()))
1478 );
1479 assert_eq!(
1480 node.attributes.get("resolved_path"),
1481 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1482 );
1483 std::fs::remove_dir_all(&dir).ok();
1484 }
1485
1486 #[test]
1487 fn bibliography_named_path_resolves_against_source_dir() {
1488 let dir = unique_temp_dir("named");
1491 let sub = dir.join("sources");
1492 std::fs::create_dir_all(&sub).unwrap();
1493 let bib = sub.join("refs.bib");
1494 std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1495 let source = dir.join("main.mos");
1496 std::fs::write(&source, "#bibliography(path: \"sources/refs.bib\")\n").unwrap();
1497 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1498 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1499 let node = r
1500 .document
1501 .nodes()
1502 .find(|n| n.kind == NodeKind::Bibliography)
1503 .expect("Bibliography node");
1504 assert_eq!(
1505 node.attributes.get("resolved_path"),
1506 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1507 );
1508 std::fs::remove_dir_all(&dir).ok();
1509 }
1510
1511 #[test]
1512 fn bibliography_src_alias_resolves_against_source_dir() {
1513 let dir = unique_temp_dir("src-alias");
1516 let sub = dir.join("sources");
1517 std::fs::create_dir_all(&sub).unwrap();
1518 let bib = sub.join("refs.bib");
1519 std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1520 let source = dir.join("main.mos");
1521 std::fs::write(&source, "#bibliography(src: \"sources/refs.bib\")\n").unwrap();
1522 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1523 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1524 let node = r
1525 .document
1526 .nodes()
1527 .find(|n| n.kind == NodeKind::Bibliography)
1528 .expect("Bibliography node");
1529 assert_eq!(
1530 node.attributes.get("src"),
1531 Some(&AttrValue::Str("sources/refs.bib".to_owned()))
1532 );
1533 assert_eq!(
1534 node.attributes.get("resolved_path"),
1535 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1536 );
1537 std::fs::remove_dir_all(&dir).ok();
1538 }
1539
1540 #[test]
1541 fn known_citation_key_resolves_against_bibliography_records() {
1542 let dir = unique_temp_dir("citation-known");
1546 let bib = dir.join("refs.bib");
1547 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
1548 let source = dir.join("main.mos");
1549 let source_text =
1550 "#bibliography(\"refs.bib\")\n\n= Intro <intro>\n\nsee [@smith2024] and @intro\n";
1551 std::fs::write(&source, source_text).unwrap();
1552
1553 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1554 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1555
1556 let citation = r
1557 .document
1558 .nodes()
1559 .find(|n| n.kind == NodeKind::Citation)
1560 .expect("Citation node");
1561 assert_eq!(
1562 citation.attributes.get("resolved"),
1563 Some(&AttrValue::Bool(true)),
1564 "known key should be marked resolved for later rendering"
1565 );
1566 assert_eq!(
1567 citation.attributes.get("text"),
1568 Some(&AttrValue::Str("[1]".to_owned())),
1569 "a resolved citation renders its first-use numeric label"
1570 );
1571 assert_eq!(
1572 citation.attributes.get("target_path"),
1573 Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1574 );
1575 assert_eq!(
1576 citation.attributes.get("target_span.start"),
1577 Some(&AttrValue::Int(9))
1578 );
1579 assert_eq!(
1580 citation.attributes.get("target_span.end"),
1581 Some(&AttrValue::Int(18))
1582 );
1583
1584 let reference = r
1585 .document
1586 .nodes()
1587 .find(|n| n.kind == NodeKind::Reference)
1588 .expect("Reference node");
1589 assert_eq!(
1590 reference.attributes.get("text"),
1591 Some(&AttrValue::Str("1".to_owned())),
1592 "label references still resolve while citations are checked"
1593 );
1594
1595 std::fs::remove_dir_all(&dir).ok();
1596 }
1597
1598 fn bibliography_entries(document: &Document) -> Vec<(i64, String, String)> {
1601 let bib = document
1602 .nodes()
1603 .find(|n| n.kind == NodeKind::Bibliography)
1604 .expect("Bibliography node");
1605 bib.children
1606 .iter()
1607 .filter_map(|id| document.get(*id))
1608 .map(|entry| {
1609 let Some(AttrValue::Int(number)) = entry.attributes.get("entry_number") else {
1610 panic!("entry paragraph without entry_number: {entry:?}");
1611 };
1612 let Some(AttrValue::Str(key)) = entry.attributes.get("entry_key") else {
1613 panic!("entry paragraph without entry_key: {entry:?}");
1614 };
1615 let text = entry
1616 .children
1617 .iter()
1618 .filter_map(|id| document.get(*id))
1619 .find_map(|child| match child.attributes.get("text") {
1620 Some(AttrValue::Str(text)) => Some(text.clone()),
1621 _ => None,
1622 })
1623 .expect("entry paragraph without a Text child");
1624 (*number, key.clone(), text)
1625 })
1626 .collect()
1627 }
1628
1629 #[test]
1630 fn cited_entries_render_in_first_use_order() {
1631 let dir = unique_temp_dir("entries-order");
1635 std::fs::write(
1636 dir.join("refs.bib"),
1637 "@book{a, title={Alpha}, author={Ada Author}, year={1990}}\n\
1638 @book{b, title={Beta}, publisher={Beta House}, year={1991}}\n",
1639 )
1640 .unwrap();
1641 let source = dir.join("main.mos");
1642 std::fs::write(
1643 &source,
1644 "#bibliography(\"refs.bib\")\n\nsee [@b] then [@a]\n",
1645 )
1646 .unwrap();
1647
1648 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1649 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1650
1651 let entries = bibliography_entries(&r.document);
1652 assert_eq!(entries.len(), 2, "{entries:?}");
1653 assert_eq!(entries[0].0, 1);
1654 assert_eq!(entries[0].1, "b");
1655 assert_eq!(entries[0].2, "Beta. Beta House. 1991.");
1656 assert_eq!(entries[1].0, 2);
1657 assert_eq!(entries[1].1, "a");
1658 assert_eq!(entries[1].2, "Ada Author. Alpha. 1990.");
1659
1660 std::fs::remove_dir_all(&dir).ok();
1661 }
1662
1663 #[test]
1664 fn repeated_and_uncited_keys_render_one_entry_each_or_none() {
1665 let dir = unique_temp_dir("entries-dedup");
1669 std::fs::write(
1670 dir.join("refs.bib"),
1671 "@book{a, title={Alpha}}\n@book{b, title={Beta}}\n",
1672 )
1673 .unwrap();
1674 let source = dir.join("main.mos");
1675 std::fs::write(
1676 &source,
1677 "#bibliography(\"refs.bib\")\n\nsee [@a] and [@a] again\n",
1678 )
1679 .unwrap();
1680
1681 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1682 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1683
1684 let entries = bibliography_entries(&r.document);
1685 assert_eq!(entries.len(), 1, "{entries:?}");
1686 assert_eq!(entries[0], (1, "a".to_owned(), "Alpha.".to_owned()));
1687
1688 std::fs::remove_dir_all(&dir).ok();
1689 }
1690
1691 #[test]
1692 fn unresolved_keys_produce_no_bibliography_entries() {
1693 let dir = unique_temp_dir("entries-unresolved");
1696 std::fs::write(dir.join("refs.bib"), "@book{real, title={Real}}\n").unwrap();
1697 let source = dir.join("main.mos");
1698 std::fs::write(&source, "#bibliography(\"refs.bib\")\n\nsee [@phantom]\n").unwrap();
1699
1700 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1701 assert!(
1702 r.diagnostics
1703 .iter()
1704 .any(|d| d.def().code() == codes::MOS0045.code()),
1705 "unknown key keeps MOS0045: {:?}",
1706 r.diagnostics
1707 );
1708
1709 let entries = bibliography_entries(&r.document);
1710 assert!(entries.is_empty(), "{entries:?}");
1711
1712 std::fs::remove_dir_all(&dir).ok();
1713 }
1714
1715 #[test]
1716 fn label_reference_matching_bib_key_suggests_citation() {
1717 let dir = unique_temp_dir("ref-is-bibkey");
1721 let bib = dir.join("refs.bib");
1722 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
1723 let source = dir.join("main.mos");
1724 let source_text = "#bibliography(\"refs.bib\")\n\nsee @smith2024 here\n";
1725 std::fs::write(&source, source_text).unwrap();
1726
1727 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1728
1729 let diag = r
1730 .diagnostics
1731 .iter()
1732 .find(|d| d.def().code() == codes::MOS0033.code())
1733 .expect("MOS0033 for the @smith2024 reference");
1734 let suggestions = diag.suggestions();
1735 assert_eq!(
1736 suggestions.len(),
1737 1,
1738 "one citation fix, got {suggestions:?}"
1739 );
1740 assert_eq!(suggestions[0].replacement, "[@smith2024]");
1741 assert_eq!(
1742 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
1743 "@smith2024",
1744 "the fix replaces the whole `@key` token, sigil included"
1745 );
1746
1747 std::fs::remove_dir_all(&dir).ok();
1748 }
1749
1750 #[test]
1751 fn repeated_known_citation_key_reuses_its_first_number() {
1752 let dir = unique_temp_dir("citation-repeat");
1755 let bib = dir.join("refs.bib");
1756 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
1757 let source = dir.join("main.mos");
1758 let source_text =
1759 "#bibliography(\"refs.bib\")\n\nsee [@smith2024] and again [@smith2024]\n";
1760 std::fs::write(&source, source_text).unwrap();
1761
1762 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1763 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1764
1765 let labels: Vec<Option<AttrValue>> = r
1766 .document
1767 .nodes()
1768 .filter(|n| n.kind == NodeKind::Citation)
1769 .map(|n| n.attributes.get("text").cloned())
1770 .collect();
1771 assert_eq!(
1772 labels,
1773 vec![
1774 Some(AttrValue::Str("[1]".to_owned())),
1775 Some(AttrValue::Str("[1]".to_owned())),
1776 ],
1777 "repeated key reuses its first-use number"
1778 );
1779
1780 std::fs::remove_dir_all(&dir).ok();
1781 }
1782
1783 #[test]
1784 fn distinct_known_citation_keys_number_by_first_use_order() {
1785 let dir = unique_temp_dir("citation-order");
1789 let bib = dir.join("refs.bib");
1790 std::fs::write(
1792 &bib,
1793 "@article{alpha, title={A}}\n@article{beta, title={B}}\n",
1794 )
1795 .unwrap();
1796 let source = dir.join("main.mos");
1797 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@beta] then [@alpha] and [@beta]\n";
1799 std::fs::write(&source, source_text).unwrap();
1800
1801 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1802 assert!(!r.has_errors(), "{:?}", r.diagnostics);
1803
1804 let labels: Vec<Option<AttrValue>> = r
1805 .document
1806 .nodes()
1807 .filter(|n| n.kind == NodeKind::Citation)
1808 .map(|n| n.attributes.get("text").cloned())
1809 .collect();
1810 assert_eq!(
1811 labels,
1812 vec![
1813 Some(AttrValue::Str("[1]".to_owned())),
1814 Some(AttrValue::Str("[2]".to_owned())),
1815 Some(AttrValue::Str("[1]".to_owned())),
1816 ],
1817 "numbering follows first citation, not bibliography source order"
1818 );
1819
1820 std::fs::remove_dir_all(&dir).ok();
1821 }
1822
1823 #[test]
1824 fn unknown_citation_key_emits_mos0045_with_source_span() {
1825 let dir = unique_temp_dir("citation-unknown");
1826 let bib = dir.join("refs.bib");
1827 std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
1828 let source = dir.join("main.mos");
1829 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@missing]\n";
1830 std::fs::write(&source, source_text).unwrap();
1831
1832 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1833 let missing: Vec<&Diagnostic> = r
1834 .diagnostics
1835 .iter()
1836 .filter(|d| d.def().code() == codes::MOS0045.code())
1837 .collect();
1838 assert_eq!(
1839 missing.len(),
1840 1,
1841 "expected one MOS0045, got {:?}",
1842 r.diagnostics
1843 );
1844 let diagnostic = missing[0];
1845 assert!(
1846 diagnostic.message().contains("`missing`"),
1847 "diagnostic should name missing citation key, got {:?}",
1848 diagnostic.message()
1849 );
1850 assert_eq!(
1851 diagnostic
1852 .span()
1853 .map(|span| &source_text[span.start()..span.end()]),
1854 Some("[@missing]"),
1855 "MOS0045 should point at the citation token"
1856 );
1857
1858 let citation = r
1859 .document
1860 .nodes()
1861 .find(|n| n.kind == NodeKind::Citation)
1862 .expect("Citation node");
1863 assert_eq!(
1864 citation.attributes.get("text"),
1865 Some(&AttrValue::Str("[?missing?]".to_owned())),
1866 "unknown citations keep visible placeholder text"
1867 );
1868 assert_eq!(citation.attributes.get("resolved"), None);
1869
1870 std::fs::remove_dir_all(&dir).ok();
1871 }
1872
1873 #[test]
1874 fn unknown_citation_key_suggests_nearest_loaded_key() {
1875 let dir = unique_temp_dir("citation-nearest-key");
1876 let bib = dir.join("refs.bib");
1877 std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
1878 let source = dir.join("main.mos");
1879 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@smit2024]\n";
1880 std::fs::write(&source, source_text).unwrap();
1881
1882 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1883 let diagnostic = r
1884 .diagnostics
1885 .iter()
1886 .find(|d| d.def().code() == codes::MOS0045.code())
1887 .expect("MOS0045 for missing citation key");
1888 let suggestions = diagnostic.suggestions();
1889 assert_eq!(
1890 suggestions.len(),
1891 1,
1892 "expected one nearest-key suggestion, got {suggestions:?}"
1893 );
1894 assert_eq!(suggestions[0].replacement, "smith2024");
1895 assert_eq!(
1896 &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
1897 "smit2024",
1898 "suggestion should replace only the citation key token"
1899 );
1900
1901 std::fs::remove_dir_all(&dir).ok();
1902 }
1903
1904 #[test]
1905 fn unknown_citation_key_tie_has_no_suggestion() {
1906 let dir = unique_temp_dir("citation-nearest-key-tie");
1907 let bib = dir.join("refs.bib");
1908 std::fs::write(&bib, "@article{abx, title={X}}\n@article{aby, title={Y}}\n").unwrap();
1909 let source = dir.join("main.mos");
1910 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@abc]\n";
1911 std::fs::write(&source, source_text).unwrap();
1912
1913 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1914 let diagnostic = r
1915 .diagnostics
1916 .iter()
1917 .find(|d| d.def().code() == codes::MOS0045.code())
1918 .expect("MOS0045 for missing citation key");
1919 assert!(
1920 diagnostic.suggestions().is_empty(),
1921 "ties should not produce a guess: {:?}",
1922 diagnostic.suggestions()
1923 );
1924
1925 std::fs::remove_dir_all(&dir).ok();
1926 }
1927
1928 #[test]
1929 fn multiple_unknown_citations_emit_deterministic_mos0045_diagnostics() {
1930 let dir = unique_temp_dir("citation-multiple-unknown");
1931 let bib = dir.join("refs.bib");
1932 std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
1933 let source = dir.join("main.mos");
1934 let source_text = "#bibliography(\"refs.bib\")\n\nsee [@alpha] and [@beta]\n";
1935 std::fs::write(&source, source_text).unwrap();
1936
1937 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1938 let spans: Vec<&str> = r
1939 .diagnostics
1940 .iter()
1941 .filter(|d| d.def().code() == codes::MOS0045.code())
1942 .filter_map(|d| d.span().map(|span| &source_text[span.start()..span.end()]))
1943 .collect();
1944 assert_eq!(
1945 spans,
1946 vec!["[@alpha]", "[@beta]"],
1947 "unknown citation diagnostics should follow document order"
1948 );
1949
1950 std::fs::remove_dir_all(&dir).ok();
1951 }
1952
1953 #[test]
1954 fn incomplete_bibliography_sources_do_not_emit_false_missing_citations() {
1955 let dir = unique_temp_dir("citation-incomplete-bibliography");
1956 let bib = dir.join("refs.bib");
1957 std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
1958 let source = dir.join("main.mos");
1959 let source_text = "#bibliography(\"refs.bib\")\n#bibliography(\"missing.bib\")\n\nsee [@known] and [@maybe-in-missing]\n";
1960 std::fs::write(&source, source_text).unwrap();
1961
1962 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1963 assert!(
1964 r.diagnostics
1965 .iter()
1966 .any(|d| d.def().code() == codes::MOS0041.code()),
1967 "expected missing bibliography source warning, got {:?}",
1968 r.diagnostics
1969 );
1970 assert!(
1971 !r.diagnostics
1972 .iter()
1973 .any(|d| d.def().code() == codes::MOS0045.code()),
1974 "incomplete bibliography set must not produce false MOS0045 diagnostics"
1975 );
1976
1977 let known = r
1978 .document
1979 .nodes()
1980 .filter(|n| n.kind == NodeKind::Citation)
1981 .find(|n| n.attributes.get("key") == Some(&AttrValue::Str("known".to_owned())))
1982 .expect("known citation node");
1983 assert_eq!(
1984 known.attributes.get("resolved"),
1985 Some(&AttrValue::Bool(true))
1986 );
1987
1988 std::fs::remove_dir_all(&dir).ok();
1989 }
1990
1991 #[test]
1992 fn duplicate_citation_keys_across_bibliography_sources_emit_mos0046() {
1993 let dir = unique_temp_dir("citation-duplicate-key");
1994 let first = dir.join("first.bib");
1995 let second = dir.join("second.bib");
1996 std::fs::write(&first, "@article{dup, title={First}}\n").unwrap();
1997 std::fs::write(&second, "@book{dup, title={Second}}\n").unwrap();
1998 let source = dir.join("main.mos");
1999 let source_text =
2000 "#bibliography(\"first.bib\")\n#bibliography(\"second.bib\")\n\nsee [@dup]\n";
2001 std::fs::write(&source, source_text).unwrap();
2002
2003 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2004 let duplicates: Vec<&Diagnostic> = r
2005 .diagnostics
2006 .iter()
2007 .filter(|d| d.def().code() == codes::MOS0046.code())
2008 .collect();
2009 assert_eq!(
2010 duplicates.len(),
2011 1,
2012 "expected one MOS0046, got {:?}",
2013 r.diagnostics
2014 );
2015 let diagnostic = duplicates[0];
2016 assert!(
2017 diagnostic.message().contains("`dup`"),
2018 "diagnostic should name duplicate citation key, got {:?}",
2019 diagnostic.message()
2020 );
2021 assert_eq!(
2022 diagnostic
2023 .span()
2024 .map(|span| &source_text[span.start()..span.end()]),
2025 Some("#bibliography(\"second.bib\")"),
2026 "duplicate should point at the later bibliography source"
2027 );
2028
2029 std::fs::remove_dir_all(&dir).ok();
2030 }
2031
2032 #[test]
2033 fn missing_bibliography_path_emits_mos0040() {
2034 let r = lower("#bibliography()\n", &PathBuf::from("/tmp/no-such.mos"));
2037 assert!(
2038 r.diagnostics
2039 .iter()
2040 .any(|d| d.def().code() == codes::MOS0040.code()),
2041 "expected MOS0040, got {:?}",
2042 r.diagnostics
2043 );
2044 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Bibliography));
2045 }
2046
2047 #[test]
2048 fn empty_bibliography_path_emits_mos0040() {
2049 let r = lower(
2052 "#bibliography(\"\")\n",
2053 &PathBuf::from("/tmp/whatever/main.mos"),
2054 );
2055 assert!(
2056 r.diagnostics
2057 .iter()
2058 .any(|d| d.def().code() == codes::MOS0040.code()),
2059 "expected MOS0040, got {:?}",
2060 r.diagnostics
2061 );
2062 assert!(
2063 !r.diagnostics
2064 .iter()
2065 .any(|d| d.def().code() == codes::MOS0041.code()),
2066 "empty path must not trip the filesystem warning: {:?}",
2067 r.diagnostics
2068 );
2069 }
2070
2071 #[test]
2072 fn non_string_bibliography_path_emits_type_mismatch_only() {
2073 let r = lower(
2076 "#bibliography(src: 12pt)\n",
2077 &PathBuf::from("/tmp/whatever/main.mos"),
2078 );
2079 assert!(
2080 r.diagnostics
2081 .iter()
2082 .any(|d| d.def().code() == codes::MOS0020.code()),
2083 "expected MOS0020, got {:?}",
2084 r.diagnostics
2085 );
2086 assert!(
2087 !r.diagnostics
2088 .iter()
2089 .any(|d| d.def().code() == codes::MOS0040.code()),
2090 "non-string path must not also emit MOS0040: {:?}",
2091 r.diagnostics
2092 );
2093 assert!(
2094 !r.diagnostics
2095 .iter()
2096 .any(|d| d.def().code() == codes::MOS0041.code()),
2097 "non-string path must not reach filesystem warning: {:?}",
2098 r.diagnostics
2099 );
2100 assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Bibliography));
2101 }
2102
2103 #[test]
2104 fn duplicate_bibliography_path_keeps_first_path() {
2105 let dir = unique_temp_dir("duplicate-path");
2109 let first = dir.join("first.bib");
2110 let second = dir.join("second.bib");
2111 std::fs::write(&first, "@book{{first}}\n").unwrap();
2112 std::fs::write(&second, "@book{{second}}\n").unwrap();
2113 let source = dir.join("main.mos");
2114 let source_text = "#bibliography(\"first.bib\", path: \"second.bib\")\n";
2115 std::fs::write(&source, source_text).unwrap();
2116 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2117 let duplicate_path_diagnostics: Vec<&Diagnostic> = r
2118 .diagnostics
2119 .iter()
2120 .filter(|d| d.def().code() == codes::MOS0042.code())
2121 .collect();
2122 assert_eq!(
2123 duplicate_path_diagnostics.len(),
2124 1,
2125 "expected one MOS0042, got {:?}",
2126 r.diagnostics
2127 );
2128 let duplicate = duplicate_path_diagnostics[0];
2129 assert_eq!(
2130 duplicate
2131 .span()
2132 .map(|span| &source_text[span.start()..span.end()]),
2133 Some("\"second.bib\""),
2134 "duplicate path diagnostic should point at the later path value"
2135 );
2136 let node = r
2137 .document
2138 .nodes()
2139 .find(|n| n.kind == NodeKind::Bibliography)
2140 .expect("Bibliography node");
2141 assert_eq!(
2142 node.attributes.get("src"),
2143 Some(&AttrValue::Str("first.bib".to_owned()))
2144 );
2145 assert_eq!(
2146 node.attributes.get("resolved_path"),
2147 Some(&AttrValue::Str(first.to_string_lossy().into_owned()))
2148 );
2149 std::fs::remove_dir_all(&dir).ok();
2150 }
2151
2152 #[test]
2153 fn missing_bibliography_source_warns_mos0041_but_keeps_node() {
2154 let dir = unique_temp_dir("absent");
2158 let source = dir.join("main.mos");
2159 std::fs::write(&source, "#bibliography(\"nope.bib\")\n").unwrap();
2160 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2161 assert!(
2162 !r.has_errors(),
2163 "a missing source is a warning, not an error: {:?}",
2164 r.diagnostics
2165 );
2166 assert!(
2167 r.diagnostics
2168 .iter()
2169 .any(|d| d.def().code() == codes::MOS0041.code()),
2170 "expected MOS0041, got {:?}",
2171 r.diagnostics
2172 );
2173 let node = r
2174 .document
2175 .nodes()
2176 .find(|n| n.kind == NodeKind::Bibliography)
2177 .expect("Bibliography node still emitted on a missing source");
2178 assert_eq!(
2179 node.attributes.get("resolved_path"),
2180 Some(&AttrValue::Str(
2181 dir.join("nope.bib").to_string_lossy().into_owned()
2182 ))
2183 );
2184 std::fs::remove_dir_all(&dir).ok();
2185 }
2186
2187 #[test]
2188 fn unknown_bibliography_arg_emits_mos0015() {
2189 let dir = unique_temp_dir("unknownarg");
2193 std::fs::write(dir.join("refs.bib"), "@book{a}\n").unwrap();
2194 let source = dir.join("main.mos");
2195 std::fs::write(&source, "#bibliography(\"refs.bib\", style: \"ieee\")\n").unwrap();
2196 let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2197 assert!(
2198 r.diagnostics
2199 .iter()
2200 .any(|d| d.def().code() == codes::MOS0015.code()),
2201 "expected MOS0015, got {:?}",
2202 r.diagnostics
2203 );
2204 std::fs::remove_dir_all(&dir).ok();
2205 }
2206
2207 fn find_unknown_arg_diagnostic(diagnostics: &[Diagnostic]) -> &Diagnostic {
2210 diagnostics
2211 .iter()
2212 .find(|d| d.def().code() == codes::MOS0015.code())
2213 .expect("expected an MOS0015 unknown-argument diagnostic")
2214 }
2215
2216 #[test]
2217 fn image_unknown_arg_close_typo_suggests_key() {
2218 let src = "#image(\"x.png\", wdith: 100pt)\n";
2221 let r = lower(src, &PathBuf::from("test.mos"));
2222 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2223 let suggestions = d.suggestions();
2224 assert_eq!(
2225 suggestions.len(),
2226 1,
2227 "expected one nearest-key suggestion, got {suggestions:?}"
2228 );
2229 assert_eq!(suggestions[0].replacement, "width");
2230 assert_eq!(
2231 &src[suggestions[0].span.start()..suggestions[0].span.end()],
2232 "wdith",
2233 "fix must replace only the key token"
2234 );
2235 }
2236
2237 #[test]
2238 fn image_unknown_arg_far_name_has_no_suggestion() {
2239 let r = lower("#image(\"x.png\", zzz: 1)\n", &PathBuf::from("test.mos"));
2242 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2243 assert!(
2244 d.suggestions().is_empty(),
2245 "an unrelated key must not be guessed, got {:?}",
2246 d.suggestions()
2247 );
2248 }
2249
2250 #[test]
2251 fn figure_unknown_arg_close_typo_suggests_key() {
2252 let src = "#figure(image: \"x.png\", captoin: \"hi\")\n";
2253 let r = lower(src, &PathBuf::from("test.mos"));
2254 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2255 let suggestions = d.suggestions();
2256 assert_eq!(
2257 suggestions.len(),
2258 1,
2259 "expected one nearest-key suggestion, got {suggestions:?}"
2260 );
2261 assert_eq!(suggestions[0].replacement, "caption");
2262 assert_eq!(
2263 &src[suggestions[0].span.start()..suggestions[0].span.end()],
2264 "captoin",
2265 "fix must replace only the key token"
2266 );
2267 }
2268
2269 #[test]
2270 fn bibliography_unknown_arg_close_typo_suggests_key() {
2271 let src = "#bibliography(\"refs.bib\", pth: \"x\")\n";
2272 let r = lower(src, &PathBuf::from("test.mos"));
2273 let d = find_unknown_arg_diagnostic(&r.diagnostics);
2274 let suggestions = d.suggestions();
2275 assert_eq!(
2276 suggestions.len(),
2277 1,
2278 "expected one nearest-key suggestion, got {suggestions:?}"
2279 );
2280 assert_eq!(suggestions[0].replacement, "path");
2281 assert_eq!(
2282 &src[suggestions[0].span.start()..suggestions[0].span.end()],
2283 "pth",
2284 "fix must replace only the key token"
2285 );
2286 }
2287}