Skip to main content

mos_eval/
lib.rs

1//! Expression and scripting evaluator (manifest §4, §25).
2//!
3//! The "evaluator" is really a *lowerer + resolver*: it walks a
4//! [`SyntaxTree`] from `mos-parse` and builds the typed semantic
5//! [`Document`] graph from `mos-core` (manifest §6 stage 2), then
6//! runs the [`resolve`](resolve()) pass to assign section numbers and rewrite
7//! `@label` cross-references (§6 stage 3, MVP 1).
8
9#![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";
52/// Attribute key holding a node's attached `/** … */` doc-comment text. Read
53/// by the LSP hover handler.
54pub 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        // AttrValue::Int is i64 while SourceSpan offsets are usize. If a future
63        // source can exceed that range, omit the fix-it span instead of storing
64        // a lossy edit location; the resolver will skip the unsafe suggestion.
65        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/// Document-level metadata harvested from `#set document(...)` directives.
72///
73/// The PDF backend writes `title` and `author` to the Info dictionary;
74/// `language` is captured for the catalog `/Lang` entry that the next
75/// PDF-metadata slice will wire up.
76///
77/// # Examples
78///
79/// ```
80/// use mos_eval::DocumentMetadata;
81///
82/// let metadata = DocumentMetadata {
83///     title: Some("Demo".to_owned()),
84///     author: None,
85///     language: Some("en".to_owned()),
86/// };
87///
88/// assert_eq!(metadata.title.as_deref(), Some("Demo"));
89/// ```
90#[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/// Result of lowering a [`SyntaxTree`] into a [`Document`].
98///
99/// # Examples
100///
101/// ```
102/// use std::path::Path;
103///
104/// use mos_core::CollectingSink;
105/// use mos_eval::{Evaluator, LowerResult};
106///
107/// let mut sink = CollectingSink::new();
108/// let parse_result = mos_parse::parse("= Hello\n", Path::new("main.mos"), &mut sink);
109/// assert!(
110///     parse_result.is_ok(),
111///     "parse structurally aborted: {parse_result:?}"
112/// );
113/// if let Ok(tree) = parse_result {
114///     let result: LowerResult = Evaluator::evaluate(&tree);
115///
116///     assert!(!result.has_errors());
117/// }
118/// ```
119#[derive(Debug)]
120pub struct LowerResult {
121    pub document: Document,
122    pub diagnostics: Vec<Diagnostic>,
123    pub metadata: DocumentMetadata,
124    /// Whether lowering this document read external files: `#image` /
125    /// `#figure` image loads and `#bibliography` source reads. Such a
126    /// lowering is **not a pure function of the source text**: the same
127    /// `(src, file)` can lower differently as referenced files appear,
128    /// change, or fail to load. Callers that cache a `LowerResult` across
129    /// time (e.g. the language server's per-document memo) must not reuse
130    /// one with this set, since an external change would make it stale
131    /// without any source edit to invalidate it.
132    pub reads_external_resources: bool,
133}
134
135impl LowerResult {
136    /// Return whether any lowering diagnostic is an error.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use std::path::Path;
142    ///
143    /// let result = mos_eval::lower("= Hello\n", Path::new("main.mos"));
144    ///
145    /// assert!(!result.has_errors());
146    /// ```
147    #[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/// Lowerer from parse syntax to semantic document graph.
156///
157/// # Examples
158///
159/// ```
160/// use mos_eval::Evaluator;
161///
162/// let evaluator = Evaluator::new();
163///
164/// assert_eq!(format!("{evaluator:?}"), "Evaluator");
165/// ```
166#[derive(Copy, Clone, Default, Debug)]
167pub struct Evaluator;
168
169impl Evaluator {
170    /// Construct an evaluator.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use mos_eval::Evaluator;
176    ///
177    /// let evaluator = Evaluator::new();
178    ///
179    /// assert_eq!(format!("{evaluator:?}"), "Evaluator");
180    /// ```
181    #[must_use]
182    pub const fn new() -> Self {
183        Self
184    }
185
186    /// Lower `tree` into a semantic [`Document`].
187    ///
188    /// # Examples
189    ///
190    /// ```
191    /// use std::path::Path;
192    ///
193    /// use mos_core::CollectingSink;
194    /// use mos_eval::Evaluator;
195    ///
196    /// let mut sink = CollectingSink::new();
197    /// let parse_result = mos_parse::parse("= Hello\n", Path::new("main.mos"), &mut sink);
198    /// assert!(
199    ///     parse_result.is_ok(),
200    ///     "parse structurally aborted: {parse_result:?}"
201    /// );
202    /// if let Ok(tree) = parse_result {
203    ///     let result = Evaluator::evaluate(&tree);
204    ///
205    ///     assert_eq!(result.document.len(), 3);
206    /// }
207    /// ```
208    #[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    /// Text of a `/** … */` doc comment seen but not yet attached. The next
225    /// documentable block (heading, paragraph) consumes it as a `doc`
226    /// attribute; a non-documentable block (`#set`, list, raw block) clears it
227    /// so it never leaks onto a later node. `None` when no doc comment is pending.
228    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        // A `/** … */` doc comment attaches to the immediately-following
254        // documentable block (heading or paragraph). Every other block clears
255        // any pending doc so it never leaks onto a later node.
256        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/// Convenience: parse + lower + resolve in one step. Concatenates the
468/// diagnostics from each stage so callers can render them uniformly.
469///
470/// # Examples
471///
472/// ```
473/// use std::path::Path;
474///
475/// let result = mos_eval::lower("= Hello\n", Path::new("main.mos"));
476///
477/// assert!(!result.has_errors());
478/// assert_eq!(result.document.len(), 3);
479/// ```
480#[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        // `CollectingSink` never asks the parser to abort; this arm is
486        // unreachable in practice but keeps the pipeline total.
487        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                // Parse aborted before any directive ran: no external reads.
493                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/// Lower an already-parsed [`SyntaxTree`].
509///
510/// This evaluates it, then runs the §6 stage-3 resolver. The CLI calls this
511/// *after* `mos_parse::parse` so a phase barrier can sit between parsing and
512/// lowering; [`lower`] is the parse-and-lower convenience used by tests and
513/// embedders.
514///
515/// # Examples
516///
517/// ```
518/// use std::path::Path;
519///
520/// let mut sink = mos_core::CollectingSink::new();
521/// let tree = mos_parse::parse(
522///     "= Intro <intro>\n\nSee @intro\n",
523///     Path::new("main.mos"),
524///     &mut sink,
525/// )?;
526/// let lowered = mos_eval::lower_tree(&tree);
527///
528/// assert!(!lowered.has_errors());
529/// # Ok::<(), mos_core::DiagnosticAbort>(())
530/// ```
531#[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        // A `#set` between the doc comment and the heading clears the pending
613        // doc, so it never attaches to a later node.
614        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        // Pure: headings, paragraphs, and references touch no files.
691        assert!(
692            !lower("= Title <t>\n\nSee @t\n", &file).reads_external_resources,
693            "a source with no filesystem directives lowers purely"
694        );
695        // Each filesystem-reading directive marks the lowering impure: even
696        // when the referenced file is missing, since the *attempt* is what
697        // makes the result depend on external state.
698        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        // Contract `mos-lsp` go-to-definition depends on (#101, hardened by
715        // #103): a labelled declaration carries BOTH `label_span.start` and
716        // `label_span.end` as `Int`s spanning exactly the label token, so the
717        // LSP can target `<intro>` rather than the whole heading. If the
718        // lowerer ever stamps one attribute without the other, or stops
719        // stamping them: `definition.rs`'s safe fallback would silently widen
720        // the definition range with no test catching it. This locks the pair.
721        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        // Issue #116: lowered `@label` / `@page(label)` reference nodes carry
761        // the same paired `label_span.*` identifier attributes as declarations,
762        // so `mos-lsp` rename reads the editable range directly instead of
763        // deriving it from the reference node's span geometry.
764        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        // Document root + Section + Paragraph + 1 Text inside Section
801        // + 3 inline children of Paragraph (text/emphasis/text).
802        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        // The paragraph is the second top-level node (after the
835        // document root). Find its children.
836        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        // The HardBreak node must have no `text` attribute -- layout
851        // dispatch matches on kind, not on text presence.
852        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    /// Hand-craft a tiny PNG in a temp dir so the eval tests don't
862    /// depend on `examples/` paths or the workspace layout.
863    /// `::image::` (rather than `image::`) routes through the extern
864    /// `image` crate; the bare `image` identifier inside the eval
865    /// crate resolves to the local `mod image` we declared up top.
866    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        // Regression: `#image(width: 2em)` after `#set text(size: 20pt)`
953        // must yield 40pt, not 22pt (which is what the old hardcoded
954        // 11pt em base produced). The lowerer now threads the tracked
955        // body text size through to `build_image_attributes`.
956        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        // `#image("")` is a missing-path mistake, not an I/O failure.
1011        // The diagnostic surface treats it the same as omitting the
1012        // path entirely so the user sees a clear "needs a path"
1013        // message instead of `MOS0012`/`MOS0029` noise.
1014        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        // No MOS0012/MOS0029 should leak through.
1023        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        // `width: 0pt` and `width: -10pt` would otherwise produce a
1035        // zero/negative image box that sails into layout and PDF
1036        // emit. Reject at lower time with MOS0020.
1037        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        // `lower` runs the resolver, which numbers the figure and stamps
1121        // the non-breaking `Figure 1: ` supplement label onto the caption.
1122        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        // If `#figure(image: "broken.png", caption: "...")` fails to
1135        // load the image, the caller still emits MOS0012; the lowerer
1136        // must NOT leave a Figure (or its caption paragraph) hanging
1137        // on the document root. A caption-only figure renders next
1138        // to whatever the user thought they were captioning, which
1139        // is worse than no output for the failed block.
1140        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        // End-to-end: a real `#figure(label: ...)` lowers with its label
1158        // on the Figure node, the resolver numbers the figure, and an
1159        // `@label` reference rewrites to kind-aware `Figure 1` text. Note
1160        // the space before `here.`: a `.` flush against the reference
1161        // would be absorbed into the label (`fig:plot.`) and miss.
1162        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        // The caption text is stamped with the visible, non-breaking label.
1190        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        // `@page(label)` reaches the semantic model as a distinct
1223        // `NodeKind::PageReference` carrying the bare label and a `?label?`
1224        // placeholder (the unresolved-reference pattern). This slice models the
1225        // node but does not resolve it: page resolution is the resolve↔layout
1226        // fixpoint (issue #72), so it must NOT be folded into the cross-
1227        // reference machinery and the placeholder must survive lowering. The
1228        // label is declared so the lower-time validation stays quiet here.
1229        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        // A page reference is its own kind, not an `@label` cross-reference.
1250        assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1251    }
1252
1253    #[test]
1254    fn undeclared_page_reference_label_emits_mos0033() {
1255        // An undeclared label in `@page(...)` is a lower-time error, exactly
1256        // like a bad `@ref`: `mos check` reports it without laying out.
1257        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        // The placeholder survives so the page reference stays visible.
1266        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        // A page reference *consumes* a label; it must not be mistaken for a
1280        // second declaration of `intro`, which would wrongly emit MOS0030.
1281        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        // `[@key]` must reach the semantic model as `NodeKind::Citation`
1297        // with the bare key in the `key` attribute and a span that
1298        // covers the full `[@key]` source extent. The placeholder
1299        // `text` attribute mirrors the unresolved-reference pattern
1300        // so layout still renders something visible before citation
1301        // display rendering exists.
1302        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        // `[@]` with an empty key must surface as a parse warning
1331        // (MOS0039) and produce zero `NodeKind::Citation` nodes: the
1332        // semantic model only carries citations that parsed cleanly.
1333        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        // Regression: an unterminated `[@key` used to advance just past
1352        // `[`, leaving `@key` to be re-tokenized by the `@`-reference
1353        // branch. The resolver then surfaced a bogus `MOS0033 unknown
1354        // label` on what was a citation typo, not a label typo.
1355        // Recovery in the parser now consumes the malformed citation
1356        // extent end-to-end so no phantom `Reference` reaches the
1357        // resolver.
1358        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        // `[@a; @b]` is the pandoc multi-key form and is deferred to
1388        // a later bibliography slice. Until then it must round-trip
1389        // as a single `MOS0039` warning with zero `Citation`/`Reference`
1390        // nodes and zero `MOS0033` follow-on errors from the resolver.
1391        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        // `#figure("path.png")` is the captionless short form. The
1419        // parser accepts it; the lowerer used to reject it with MOS0024,
1420        // which broke the spelling end-to-end.
1421        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        // One child: just the image (no caption was supplied).
1432        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    /// Create a unique temp dir for a bibliography test. Salted with the
1443    /// caller's `name` plus a high-resolution timestamp so parallel tests
1444    /// don't collide, mirroring `write_tiny_png`.
1445    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        // A declared `#bibliography("refs.bib")` lowers to a Bibliography
1460        // node that preserves both the literal `src` and the path resolved
1461        // against the source file's directory, so the later BibTeX reader
1462        // can open the database. With the file present there is no warning.
1463        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        // The named `path:` form resolves a subdirectory-relative path the
1489        // same way, exercising project-relative resolution explicitly.
1490        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        // The `src:` alias is accepted for parity with image source naming,
1514        // and preserves the literal source path for the later BibTeX reader.
1515        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        // A citation key declared in the parsed BibTeX source is marked
1543        // resolved and its visible text is rewritten to its first-use
1544        // numeric label `[1]` (issue #67).
1545        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    /// The `(entry_number, entry_key, rendered text)` triples attached to
1599    /// the first Bibliography node, in child order.
1600    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        // Citing `[@b]` before `[@a]` numbers b=1, a=2; the bibliography
1632        // node's entry children follow that first-use order, not BibTeX
1633        // declaration order (issue #112).
1634        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        // `[@a]` cited twice renders one entry; the never-cited `b` record
1666        // contributes nothing (uncited entries are out of scope for the
1667        // numeric slice).
1668        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        // An unknown key keeps its MOS0045 diagnostic and never occupies a
1694        // numbered entry slot.
1695        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        // `@smith2024` resolves to no label but exactly matches a bibliography
1718        // key -- the user meant the citation `[@smith2024]`. MOS0033 carries a
1719        // structured fix swapping `@smith2024` for `[@smith2024]`.
1720        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        // Two citations to the same resolved key render the same numeric
1753        // label -- a key is numbered once, on first use.
1754        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        // Distinct resolved keys are numbered by the order they are first
1786        // cited, independent of their order in the BibTeX source, and a
1787        // later repeat of an earlier key keeps that key's number.
1788        let dir = unique_temp_dir("citation-order");
1789        let bib = dir.join("refs.bib");
1790        // `alpha` precedes `beta` in the database file...
1791        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        // ...but `beta` is *cited* first, so beta -> [1] and alpha -> [2].
1798        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        // `#bibliography()` with no path is the same authoring mistake as
2035        // `#image()`: a hard error, and no node leaks into the document.
2036        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        // `#bibliography("")` is a missing-path mistake, not an I/O failure;
2050        // it surfaces as MOS0040 and never reaches the filesystem check.
2051        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        // A path-shaped arg with the wrong type is not "missing"; report
2074        // the type mismatch once and do not also emit missing-path/I/O noise.
2075        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        // Duplicate path declarations are an authoring error, but the first
2106        // source still wins so later accidental args cannot silently redirect
2107        // the bibliography boundary.
2108        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        // A declared-but-absent database is a non-fatal warning: the build
2155        // still succeeds and the node is emitted with its resolved path so
2156        // the later BibTeX slice can act on it.
2157        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        // Arguments beyond the path (e.g. a future `style:`) are rejected
2190        // now so the directive's surface stays narrow until later slices
2191        // grow it deliberately.
2192        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    /// The single MOS0015 diagnostic in `diagnostics`, or a panic naming
2208    /// what was found instead.
2209    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        // `wdith` is one adjacent transposition from `width`; the MOS0015
2219        // carries a machine-applicable fix replacing only the key token.
2220        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        // An unrelated key gets the plain diagnostic: a wrong guess is
2240        // worse than none.
2241        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}