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)]
13mod bibliography;
14mod dependency;
15#[doc(hidden)]
16pub mod image;
17mod image_lower;
18#[doc(hidden)]
19pub mod inline;
20#[doc(hidden)]
21pub mod list;
22#[doc(hidden)]
23pub mod pageref;
24#[doc(hidden)]
25pub mod resolve;
26mod semantic_hash;
27#[doc(hidden)]
28pub mod set;
29#[doc(hidden)]
30pub mod set_schema;
31mod suggest;
32
33use std::collections::{BTreeMap, BTreeSet};
34
35use mos_core::{
36    AttrMap, AttrValue, CollectingSink, Diagnostic, Document, NodeId, NodeKind, NodeSpec, Severity,
37    SourceSpan,
38};
39use mos_parse::{DirectiveKind, Item, RawBlockKind, RawBlockView, SetArg, SetValue, SyntaxTree};
40
41pub use dependency::{
42    ExternalDependency, FileIdentity, Fingerprint, RACY_WINDOW, fingerprint_bytes, fingerprint_file,
43};
44pub use mos_bib::Bibliography;
45pub use pageref::{PageFixpointOutcome, resolve_page_reference_fixpoint, resolve_page_references};
46pub use resolve::resolve;
47
48use bibliography::{load_bibliography, lower_bibliography_directive, resolve_citations};
49use dependency::{DependencySet, ExternalInputs};
50use image_lower::{lower_figure_directive, lower_image_directive};
51use inline::lower_inlines;
52use list::lower as lower_list;
53use set::lower_set_directive;
54
55const LABEL_SPAN_START_ATTR: &str = "label_span.start";
56const LABEL_SPAN_END_ATTR: &str = "label_span.end";
57/// Attribute key holding a node's attached `/** … */` doc-comment text. Read
58/// by the LSP hover handler.
59pub const DOC_ATTR: &str = "doc";
60/// Authored language name on a `#code` node, available to editor consumers.
61pub const CODE_LANGUAGE_ATTR: &str = "raw.lang";
62
63fn insert_label_attributes(attributes: &mut AttrMap, label: &str, label_span: Option<&SourceSpan>) {
64    attributes.insert("label".to_owned(), AttrValue::Str(label.to_owned()));
65    let Some(span) = label_span else {
66        return;
67    };
68    let (Ok(start), Ok(end)) = (i64::try_from(span.start()), i64::try_from(span.end())) else {
69        // AttrValue::Int is i64 while SourceSpan offsets are usize. If a future
70        // source can exceed that range, omit the fix-it span instead of storing
71        // a lossy edit location; the resolver will skip the unsafe suggestion.
72        return;
73    };
74    attributes.insert(LABEL_SPAN_START_ATTR.to_owned(), AttrValue::Int(start));
75    attributes.insert(LABEL_SPAN_END_ATTR.to_owned(), AttrValue::Int(end));
76}
77
78/// The span of a string literal's contents, excluding its quotes.
79pub(crate) fn string_content_span(value_span: &SourceSpan) -> SourceSpan {
80    if value_span.end() > value_span.start().saturating_add(1) {
81        SourceSpan::new(
82            value_span.file.clone(),
83            value_span.start() + 1,
84            value_span.end() - 1,
85        )
86    } else {
87        value_span.clone()
88    }
89}
90
91/// Document-level metadata harvested from `#set document(...)` directives.
92///
93/// The PDF backend writes `title` and `author` to the Info dictionary;
94/// `language` is captured for the catalog `/Lang` entry that the next
95/// PDF-metadata slice will wire up.
96///
97/// # Examples
98///
99/// ```
100/// use mos_eval::DocumentMetadata;
101///
102/// let metadata = DocumentMetadata {
103///     title: Some("Demo".to_owned()),
104///     author: None,
105///     language: Some("en".to_owned()),
106/// };
107///
108/// assert_eq!(metadata.title.as_deref(), Some("Demo"));
109/// ```
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct DocumentMetadata {
112    pub title: Option<String>,
113    pub author: Option<String>,
114    pub language: Option<String>,
115}
116
117/// Result of lowering a [`SyntaxTree`] into a [`Document`].
118///
119/// # Examples
120///
121/// ```
122/// use std::path::Path;
123///
124/// use mos_core::CollectingSink;
125/// use mos_eval::{Evaluator, LowerResult};
126///
127/// let mut sink = CollectingSink::new();
128/// let parse_result = mos_parse::parse("= Hello\n", Path::new("main.mos"), &mut sink);
129/// assert!(
130///     parse_result.is_ok(),
131///     "parse structurally aborted: {parse_result:?}"
132/// );
133/// if let Ok(tree) = parse_result {
134///     let result: LowerResult = Evaluator::evaluate(&tree);
135///
136///     assert!(!result.has_errors());
137/// }
138/// ```
139#[derive(Debug)]
140pub struct LowerResult {
141    /// Authored nodes carry [`mos_core::Node::content_hash`] snapshots taken
142    /// before resolution. Generated bibliography entries keep the default hash.
143    pub document: Document,
144    pub diagnostics: Vec<Diagnostic>,
145    pub metadata: DocumentMetadata,
146    /// Every external file this lowering read (`#image` / `#figure` rasters,
147    /// `#bibliography` sources), with the fingerprint each had at the time,
148    /// sorted by path. Such a lowering is not a pure function of the source
149    /// text: a caller that caches it across time must check
150    /// [`ExternalDependency::is_current`] on each entry before reuse. The set
151    /// is complete after [`lower`] / [`lower_tree`]; a bare
152    /// [`Evaluator::evaluate`] has not yet opened bibliography sources.
153    pub external_dependencies: Vec<ExternalDependency>,
154    /// Every BibTeX record loaded from the declared `#bibliography` sources,
155    /// merged across sources and keyed by citation key. Empty after a bare
156    /// [`Evaluator::evaluate`], which has not yet opened bibliography sources.
157    pub bibliography: Bibliography,
158    /// Whether every declared bibliography source was successfully read and
159    /// parsed. False before source loading; records may be partial when false.
160    pub bibliography_complete: bool,
161    /// Parser-recognized citation ranges, including unfinished citations.
162    /// See [`SyntaxTree::citation_spans`].
163    pub citation_spans: Vec<std::ops::Range<usize>>,
164}
165
166impl LowerResult {
167    /// Whether lowering read any external file; see
168    /// [`external_dependencies`](Self::external_dependencies).
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use std::path::Path;
174    ///
175    /// assert!(!mos_eval::lower("= Hello\n", Path::new("main.mos")).reads_external_resources());
176    /// assert!(mos_eval::lower("#image(\"x.png\")\n", Path::new("main.mos")).reads_external_resources());
177    /// ```
178    #[must_use]
179    pub fn reads_external_resources(&self) -> bool {
180        !self.external_dependencies.is_empty()
181    }
182
183    /// Return whether any lowering diagnostic is an error.
184    ///
185    /// # Examples
186    ///
187    /// ```
188    /// use std::path::Path;
189    ///
190    /// let result = mos_eval::lower("= Hello\n", Path::new("main.mos"));
191    ///
192    /// assert!(!result.has_errors());
193    /// ```
194    #[must_use]
195    pub fn has_errors(&self) -> bool {
196        self.diagnostics
197            .iter()
198            .any(|d| d.severity() == Severity::Error)
199    }
200}
201
202/// Lowerer from parse syntax to semantic document graph.
203///
204/// # Examples
205///
206/// ```
207/// use mos_eval::Evaluator;
208///
209/// let evaluator = Evaluator::new();
210///
211/// assert_eq!(format!("{evaluator:?}"), "Evaluator");
212/// ```
213#[derive(Copy, Clone, Default, Debug)]
214pub struct Evaluator;
215
216impl Evaluator {
217    /// Construct an evaluator.
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use mos_eval::Evaluator;
223    ///
224    /// let evaluator = Evaluator::new();
225    ///
226    /// assert_eq!(format!("{evaluator:?}"), "Evaluator");
227    /// ```
228    #[must_use]
229    pub const fn new() -> Self {
230        Self
231    }
232
233    /// Lower `tree` into a semantic [`Document`] and hash its authored nodes.
234    /// Bibliography sources are marked unloaded in those hashes; use
235    /// [`lower_tree`] to include their contents as well.
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// use std::path::Path;
241    ///
242    /// use mos_core::CollectingSink;
243    /// use mos_eval::Evaluator;
244    ///
245    /// let mut sink = CollectingSink::new();
246    /// let parse_result = mos_parse::parse("= Hello\n", Path::new("main.mos"), &mut sink);
247    /// assert!(
248    ///     parse_result.is_ok(),
249    ///     "parse structurally aborted: {parse_result:?}"
250    /// );
251    /// if let Ok(tree) = parse_result {
252    ///     let result = Evaluator::evaluate(&tree);
253    ///
254    ///     assert_eq!(result.document.len(), 3);
255    /// }
256    /// ```
257    #[must_use]
258    pub fn evaluate(tree: &SyntaxTree) -> LowerResult {
259        let mut lowered = Self::evaluate_unhashed(tree);
260        semantic_hash::stamp(&mut lowered.document, &lowered.external_dependencies);
261        lowered
262    }
263
264    fn evaluate_unhashed(tree: &SyntaxTree) -> LowerResult {
265        let mut state = EvaluationState::new(tree);
266        for item in &tree.items {
267            state.lower_item(item, &tree.file);
268        }
269        state.finish(tree)
270    }
271}
272
273struct EvaluationState {
274    document: Document,
275    diagnostics: Vec<Diagnostic>,
276    metadata: DocumentMetadata,
277    current_text_size_pt: f64,
278    dependencies: DependencySet,
279    /// Text of a `/** … */` doc comment seen but not yet attached. The next
280    /// documentable block (heading, paragraph) consumes it as a `doc`
281    /// attribute; a non-documentable block (`#set`, list, raw block) clears it
282    /// so it never leaks onto a later node. `None` when no doc comment is pending.
283    pending_doc: Option<String>,
284}
285
286impl EvaluationState {
287    fn new(tree: &SyntaxTree) -> Self {
288        Self {
289            document: Document::new(tree.file.clone()),
290            diagnostics: Vec::new(),
291            metadata: DocumentMetadata::default(),
292            current_text_size_pt: 11.0,
293            dependencies: DependencySet::default(),
294            pending_doc: None,
295        }
296    }
297
298    fn finish(self, tree: &SyntaxTree) -> LowerResult {
299        LowerResult {
300            document: self.document,
301            diagnostics: self.diagnostics,
302            metadata: self.metadata,
303            external_dependencies: self.dependencies.into_vec(),
304            bibliography: Bibliography::default(),
305            bibliography_complete: false,
306            citation_spans: tree.citation_spans.clone(),
307        }
308    }
309
310    fn lower_item(&mut self, item: &Item, source_file: &std::path::Path) {
311        // A `/** … */` doc comment attaches to the immediately-following
312        // documentable block (heading or paragraph). Every other block clears
313        // any pending doc so it never leaks onto a later node.
314        match item {
315            Item::DocComment { text, .. } => {
316                self.pending_doc = Some(text.clone());
317            }
318            Item::Heading {
319                level,
320                inlines,
321                label,
322                label_span,
323                span,
324            } => {
325                let doc = self.pending_doc.take();
326                self.lower_heading(
327                    *level,
328                    inlines,
329                    label.as_deref(),
330                    label_span.as_ref(),
331                    span,
332                    doc,
333                );
334            }
335            Item::Paragraph {
336                inlines,
337                label,
338                label_span,
339                span,
340            } => {
341                let doc = self.pending_doc.take();
342                self.lower_paragraph(inlines, label.as_deref(), label_span.as_ref(), span, doc);
343            }
344            Item::List {
345                ordered,
346                items,
347                span,
348            } => {
349                self.pending_doc = None;
350                let root = self.document.root;
351                lower_list(&mut self.document, root, *ordered, items, span);
352            }
353            Item::RawBlock { .. } => {
354                self.pending_doc = None;
355                let root = self.document.root;
356                if let Some(raw) = item.as_raw_block() {
357                    lower_raw_block(&mut self.document, root, raw);
358                }
359            }
360            Item::Set {
361                kind,
362                name,
363                args,
364                span,
365            } => {
366                self.pending_doc = None;
367                self.lower_directive(*kind, name, args, span, source_file);
368            }
369        }
370    }
371
372    fn lower_heading(
373        &mut self,
374        level: u8,
375        inlines: &[mos_parse::Inline],
376        label: Option<&str>,
377        label_span: Option<&SourceSpan>,
378        span: &SourceSpan,
379        doc: Option<String>,
380    ) {
381        let mut attributes: AttrMap = BTreeMap::new();
382        attributes.insert("level".to_owned(), AttrValue::Int(i64::from(level)));
383        if let Some(id) = label {
384            insert_label_attributes(&mut attributes, id, label_span);
385        }
386        if let Some(doc) = doc {
387            attributes.insert(DOC_ATTR.to_owned(), AttrValue::Str(doc));
388        }
389        let heading = self.document.alloc_child(
390            self.document.root,
391            NodeSpec::new(NodeKind::Section, span.clone()).with_attributes(attributes),
392        );
393        lower_inlines(&mut self.document, heading, inlines);
394    }
395
396    fn lower_paragraph(
397        &mut self,
398        inlines: &[mos_parse::Inline],
399        label: Option<&str>,
400        label_span: Option<&SourceSpan>,
401        span: &SourceSpan,
402        doc: Option<String>,
403    ) {
404        let mut attributes: AttrMap = BTreeMap::new();
405        if let Some(id) = label {
406            insert_label_attributes(&mut attributes, id, label_span);
407        }
408        if let Some(doc) = doc {
409            attributes.insert(DOC_ATTR.to_owned(), AttrValue::Str(doc));
410        }
411        let para = self.document.alloc_child(
412            self.document.root,
413            NodeSpec::new(NodeKind::Paragraph, span.clone()).with_attributes(attributes),
414        );
415        lower_inlines(&mut self.document, para, inlines);
416    }
417
418    fn lower_directive(
419        &mut self,
420        kind: DirectiveKind,
421        name: &str,
422        args: &[SetArg],
423        span: &SourceSpan,
424        source_file: &std::path::Path,
425    ) {
426        let mut inputs = ExternalInputs {
427            source_file,
428            dependencies: &mut self.dependencies,
429        };
430        match kind {
431            DirectiveKind::Image => {
432                let root = self.document.root;
433                lower_image_directive(
434                    &mut self.document,
435                    root,
436                    args,
437                    span,
438                    &mut inputs,
439                    self.current_text_size_pt,
440                    &mut self.diagnostics,
441                );
442            }
443            DirectiveKind::Figure => {
444                let root = self.document.root;
445                lower_figure_directive(
446                    &mut self.document,
447                    root,
448                    args,
449                    span,
450                    &mut inputs,
451                    self.current_text_size_pt,
452                    &mut self.diagnostics,
453                );
454            }
455            DirectiveKind::Bibliography => {
456                let root = self.document.root;
457                lower_bibliography_directive(
458                    &mut self.document,
459                    root,
460                    args,
461                    span,
462                    &mut inputs,
463                    &mut self.diagnostics,
464                );
465            }
466            DirectiveKind::Set => {
467                let root = self.document.root;
468                lower_set_directive(
469                    &mut self.document,
470                    root,
471                    name,
472                    args,
473                    span,
474                    &mut self.metadata,
475                    &mut self.current_text_size_pt,
476                    &mut self.diagnostics,
477                );
478            }
479        }
480    }
481}
482
483fn lower_raw_block(document: &mut Document, root: NodeId, raw: RawBlockView<'_>) {
484    let mut attributes: AttrMap = BTreeMap::new();
485    attributes.insert("text".to_owned(), AttrValue::Str(raw.text.to_owned()));
486    if let Some(id) = raw.label {
487        insert_label_attributes(&mut attributes, id, raw.label_span);
488    }
489    // Keep the authored language for editor context and semantic hashing.
490    // Other raw-block options remain uninterpreted; this does not introduce
491    // syntax highlighting, execution, or language-specific validation.
492    if raw.kind == RawBlockKind::Code
493        && let Some(SetArg::Named {
494            value: SetValue::Str(language) | SetValue::Ident(language),
495            ..
496        }) = raw
497            .args
498            .iter()
499            .rev()
500            .find(|arg| matches!(arg, SetArg::Named { key, .. } if key == "lang"))
501    {
502        attributes.insert(
503            CODE_LANGUAGE_ATTR.to_owned(),
504            AttrValue::Str(language.clone()),
505        );
506    }
507    attributes.insert(
508        "raw.kind".to_owned(),
509        AttrValue::Str(
510            match raw.kind {
511                RawBlockKind::Pre => "pre",
512                RawBlockKind::Code => "code",
513            }
514            .to_owned(),
515        ),
516    );
517    document.alloc_child(
518        root,
519        NodeSpec::new(NodeKind::Raw, raw.span.clone()).with_attributes(attributes),
520    );
521}
522
523/// Convenience: parse + lower + resolve in one step. Concatenates the
524/// diagnostics from each stage so callers can render them uniformly.
525///
526/// # Examples
527///
528/// ```
529/// use std::path::Path;
530///
531/// let result = mos_eval::lower("= Hello\n", Path::new("main.mos"));
532///
533/// assert!(!result.has_errors());
534/// assert_eq!(result.document.len(), 3);
535/// ```
536#[must_use]
537pub fn lower(src: &str, file: &std::path::Path) -> LowerResult {
538    let mut sink = CollectingSink::new();
539    let tree = match mos_parse::parse(src, file, &mut sink) {
540        Ok(tree) => tree,
541        // `CollectingSink` never asks the parser to abort; this arm is
542        // unreachable in practice but keeps the pipeline total.
543        Err(mos_core::DiagnosticAbort) => {
544            return LowerResult {
545                document: Document::new(file.to_path_buf()),
546                diagnostics: sink.into_diagnostics(),
547                metadata: DocumentMetadata::default(),
548                external_dependencies: Vec::new(),
549                bibliography: Bibliography::default(),
550                bibliography_complete: false,
551                citation_spans: Vec::new(),
552            };
553        }
554    };
555    let mut diagnostics = sink.into_diagnostics();
556    let mut lowered = lower_tree(&tree);
557    diagnostics.append(&mut lowered.diagnostics);
558    LowerResult {
559        document: lowered.document,
560        diagnostics,
561        metadata: lowered.metadata,
562        external_dependencies: lowered.external_dependencies,
563        bibliography: lowered.bibliography,
564        bibliography_complete: lowered.bibliography_complete,
565        citation_spans: lowered.citation_spans,
566    }
567}
568
569/// Lower an already-parsed [`SyntaxTree`].
570///
571/// This evaluates it, then runs the §6 stage-3 resolver. The CLI calls this
572/// *after* `mos_parse::parse` so a phase barrier can sit between parsing and
573/// lowering; [`lower`] is the parse-and-lower convenience used by tests and
574/// embedders.
575///
576/// # Examples
577///
578/// ```
579/// use std::path::Path;
580///
581/// let mut sink = mos_core::CollectingSink::new();
582/// let tree = mos_parse::parse(
583///     "= Intro <intro>\n\nSee @intro\n",
584///     Path::new("main.mos"),
585///     &mut sink,
586/// )?;
587/// let lowered = mos_eval::lower_tree(&tree);
588///
589/// assert!(!lowered.has_errors());
590/// # Ok::<(), mos_core::DiagnosticAbort>(())
591/// ```
592#[must_use]
593pub fn lower_tree(tree: &SyntaxTree) -> LowerResult {
594    let mut lowered = Evaluator::evaluate_unhashed(tree);
595    let mut diagnostics = std::mem::take(&mut lowered.diagnostics);
596    let mut dependencies = DependencySet::from(std::mem::take(&mut lowered.external_dependencies));
597    let loaded_bibliography =
598        load_bibliography(&lowered.document, &mut diagnostics, &mut dependencies);
599    let external_dependencies = dependencies.into_vec();
600    // Capture authored state after all file reads, before numbering, reference
601    // rewrites, and generated bibliography entry nodes add resolution output.
602    semantic_hash::stamp(&mut lowered.document, &external_dependencies);
603    let (bibliography, bibliography_complete) =
604        resolve_citations(&mut lowered.document, &mut diagnostics, loaded_bibliography);
605    let bib_keys: BTreeSet<String> = bibliography.entries.keys().cloned().collect();
606    diagnostics.extend(resolve(&mut lowered.document, &bib_keys));
607    LowerResult {
608        document: lowered.document,
609        diagnostics,
610        metadata: lowered.metadata,
611        external_dependencies,
612        bibliography,
613        bibliography_complete,
614        citation_spans: lowered.citation_spans,
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    #![allow(
621        clippy::unwrap_used,
622        clippy::expect_used,
623        clippy::panic,
624        reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
625    )]
626    use std::path::PathBuf;
627
628    use mos_core::{NodeKind, codes};
629
630    use super::*;
631
632    #[test]
633    fn code_language_is_authored_metadata_with_last_argument_winning() {
634        let src = "#code(lang: \"rust\", lang: python)[[print(1)]] <sample>\n";
635        let result = lower(src, &PathBuf::from("main.mos"));
636        assert!(!result.has_errors());
637        let node = result
638            .document
639            .nodes()
640            .find(|node| node.kind == NodeKind::Raw)
641            .unwrap();
642        assert_eq!(
643            node.attributes[CODE_LANGUAGE_ATTR],
644            AttrValue::Str("python".into())
645        );
646        assert_eq!(node.attributes["text"], AttrValue::Str("print(1)".into()));
647        assert_eq!(node.attributes["label"], AttrValue::Str("sample".into()));
648        let changed = lower(
649            &src.replace("lang: python", "lang: rust"),
650            &PathBuf::from("main.mos"),
651        );
652        let changed = changed
653            .document
654            .nodes()
655            .find(|node| node.kind == NodeKind::Raw)
656            .unwrap();
657        assert_ne!(changed.content_hash(), node.content_hash());
658    }
659
660    #[test]
661    fn absent_or_nontext_code_language_and_pre_options_stay_uninterpreted() {
662        for src in [
663            "#code[[body]]",
664            "#code(lang: \"rust\", lang: 42)[[body]]",
665            "#pre(lang: \"rust\")[[body]]",
666        ] {
667            let result = lower(src, &PathBuf::from("main.mos"));
668            assert!(!result.has_errors(), "{:?}", result.diagnostics);
669            let node = result
670                .document
671                .nodes()
672                .find(|node| node.kind == NodeKind::Raw)
673                .unwrap();
674            assert!(!node.attributes.contains_key(CODE_LANGUAGE_ATTR));
675            assert_eq!(node.attributes["text"], AttrValue::Str("body".into()));
676        }
677    }
678
679    #[cfg(target_pointer_width = "64")]
680    #[test]
681    fn label_attributes_omit_unrepresentable_span_bounds() {
682        let too_large = usize::try_from(i64::MAX).unwrap().saturating_add(1);
683        let span = SourceSpan::new(
684            PathBuf::from("test.mos"),
685            too_large,
686            too_large.saturating_add(1),
687        );
688        let mut attributes = AttrMap::new();
689
690        insert_label_attributes(&mut attributes, "huge", Some(&span));
691
692        assert_eq!(
693            attributes.get("label"),
694            Some(&AttrValue::Str("huge".to_owned()))
695        );
696        assert!(!attributes.contains_key(LABEL_SPAN_START_ATTR));
697        assert!(!attributes.contains_key(LABEL_SPAN_END_ATTR));
698    }
699
700    #[test]
701    fn doc_comment_attaches_to_following_heading() {
702        let file = PathBuf::from("test.mos");
703        let r = lower("/** Section doc. */\n= Intro <intro>\n", &file);
704        let section = r
705            .document
706            .nodes()
707            .find(|n| n.kind == NodeKind::Section)
708            .expect("a section node");
709        assert_eq!(
710            section.attributes.get(DOC_ATTR),
711            Some(&AttrValue::Str("Section doc.".to_owned())),
712        );
713    }
714
715    #[test]
716    fn doc_comment_attaches_to_following_paragraph() {
717        let file = PathBuf::from("test.mos");
718        let r = lower("/** Para doc. */\nBody text.\n", &file);
719        let para = r
720            .document
721            .nodes()
722            .find(|n| n.kind == NodeKind::Paragraph)
723            .expect("a paragraph node");
724        assert_eq!(
725            para.attributes.get(DOC_ATTR),
726            Some(&AttrValue::Str("Para doc.".to_owned())),
727        );
728    }
729
730    #[test]
731    fn doc_comment_does_not_leak_past_a_non_documentable_block() {
732        // A `#set` between the doc comment and the heading clears the pending
733        // doc, so it never attaches to a later node.
734        let file = PathBuf::from("test.mos");
735        let r = lower("/** stray */\n#set document(title: \"x\")\n\n= H\n", &file);
736        let section = r
737            .document
738            .nodes()
739            .find(|n| n.kind == NodeKind::Section)
740            .expect("a section node");
741        assert!(
742            !section.attributes.contains_key(DOC_ATTR),
743            "doc must not leak past #set"
744        );
745    }
746
747    #[test]
748    fn doc_comment_does_not_attach_to_or_leak_past_a_list() {
749        let file = PathBuf::from("test.mos");
750        let r = lower("/** stray */\n- item\n\n= H\n", &file);
751        let list = r
752            .document
753            .nodes()
754            .find(|n| n.kind == NodeKind::List)
755            .expect("a list node");
756        assert!(
757            !list.attributes.contains_key(DOC_ATTR),
758            "doc must not attach to a list"
759        );
760        let section = r
761            .document
762            .nodes()
763            .find(|n| n.kind == NodeKind::Section)
764            .expect("a section node");
765        assert!(
766            !section.attributes.contains_key(DOC_ATTR),
767            "doc must not leak past a list"
768        );
769    }
770
771    #[test]
772    fn doc_comment_does_not_attach_to_or_leak_past_a_raw_block() {
773        let file = PathBuf::from("test.mos");
774        let r = lower("/** stray */\n#code[[x]]\n\n= H\n", &file);
775        let raw = r
776            .document
777            .nodes()
778            .find(|n| n.kind == NodeKind::Raw)
779            .expect("a raw node");
780        assert!(
781            !raw.attributes.contains_key(DOC_ATTR),
782            "doc must not attach to a raw block"
783        );
784        let section = r
785            .document
786            .nodes()
787            .find(|n| n.kind == NodeKind::Section)
788            .expect("a section node");
789        assert!(
790            !section.attributes.contains_key(DOC_ATTR),
791            "doc must not leak past a raw block"
792        );
793    }
794
795    #[test]
796    fn plain_block_comment_attaches_no_doc() {
797        let file = PathBuf::from("test.mos");
798        let r = lower("/* not doc */\n= H\n", &file);
799        let section = r
800            .document
801            .nodes()
802            .find(|n| n.kind == NodeKind::Section)
803            .expect("a section node");
804        assert!(!section.attributes.contains_key(DOC_ATTR));
805    }
806
807    #[test]
808    fn lowering_records_external_dependencies_with_fingerprints() {
809        let dir = unique_temp_dir("deps");
810        let source = dir.join("main.mos");
811        let image = dir.join("x.png");
812        let bib = dir.join("refs.bib");
813        std::fs::write(&image, b"not a png").unwrap();
814        std::fs::write(&bib, "@book{k, title={T}}\n").unwrap();
815        let r = lower(
816            "#image(\"x.png\")\n#image(\"x.png\")\n#bibliography(\"refs.bib\")\n#image(\"missing.png\")\n",
817            &source,
818        );
819
820        let deps = &r.external_dependencies;
821        assert_eq!(
822            deps.iter().map(|d| d.path.clone()).collect::<Vec<_>>(),
823            vec![dir.join("missing.png"), bib, image.clone()],
824            "one entry per distinct file, sorted by path: {deps:?}"
825        );
826        let content = |dep: &ExternalDependency| dep.fingerprint.map(|f| f.content);
827        assert_eq!(content(&deps[0]), None, "a missing file records no hash");
828        assert_eq!(
829            content(&deps[1]),
830            Some(fingerprint_bytes(b"@book{k, title={T}}\n"))
831        );
832        assert_eq!(content(&deps[2]), Some(fingerprint_bytes(b"not a png")));
833        assert!(deps.iter().all(ExternalDependency::is_current));
834
835        std::fs::write(&image, b"changed").unwrap();
836        assert!(
837            !deps[2].is_current(),
838            "a rewritten file is no longer current"
839        );
840        assert!(deps[1].is_current());
841        std::fs::remove_dir_all(&dir).ok();
842    }
843
844    #[cfg(unix)]
845    #[test]
846    fn non_utf8_bibliography_path_warns_and_records_the_real_file() {
847        use std::ffi::OsStr;
848        use std::os::unix::ffi::OsStrExt;
849
850        let dir = unique_temp_dir("non-utf8").join(OsStr::from_bytes(b"caf\xe9"));
851        std::fs::create_dir_all(&dir).unwrap();
852        let bib = dir.join("refs.bib");
853        std::fs::write(&bib, "@book{k, title={T}}\n").unwrap();
854        let source = dir.join("main.mos");
855        let r = lower("#bibliography(\"refs.bib\")\n\nsee [@k]\n", &source);
856
857        let warning = r
858            .diagnostics
859            .iter()
860            .find(|d| d.def().code() == codes::MOS0041.code())
861            .expect("MOS0041 for the unloadable source");
862        assert!(
863            warning.message().contains("non-UTF-8"),
864            "message: {}",
865            warning.message()
866        );
867        let node = r
868            .document
869            .nodes()
870            .find(|n| n.kind == NodeKind::Bibliography)
871            .expect("the source is still declared");
872        assert_eq!(
873            node.attributes.get("src"),
874            Some(&AttrValue::Str("refs.bib".to_owned()))
875        );
876        assert!(
877            !node.attributes.contains_key("resolved_path"),
878            "a path the model cannot name is left unresolved"
879        );
880        assert!(
881            !r.diagnostics
882                .iter()
883                .any(|d| d.def().code() == codes::MOS0045.code()),
884            "an unloadable source makes the record set incomplete, so no key is reported missing"
885        );
886        assert_eq!(r.external_dependencies.len(), 1);
887        assert_eq!(r.external_dependencies[0].path, bib);
888        assert!(r.external_dependencies[0].fingerprint.is_some());
889        assert!(r.external_dependencies[0].is_current());
890        std::fs::remove_dir_all(dir.parent().unwrap()).ok();
891    }
892
893    #[test]
894    fn reads_external_resources_flags_filesystem_directives() {
895        let file = PathBuf::from("test.mos");
896        // Pure: headings, paragraphs, and references touch no files.
897        assert!(
898            !lower("= Title <t>\n\nSee @t\n", &file).reads_external_resources(),
899            "a source with no filesystem directives lowers purely"
900        );
901        // Each filesystem-reading directive marks the lowering impure: even
902        // when the referenced file is missing, since the *attempt* is what
903        // makes the result depend on external state.
904        assert!(
905            lower("#image(\"missing.png\")\n", &file).reads_external_resources(),
906            "`#image` reads an external file"
907        );
908        assert!(
909            lower("#figure(image: \"missing.png\")\n", &file).reads_external_resources(),
910            "`#figure` loads an external image"
911        );
912        assert!(
913            lower("#bibliography(\"missing.bib\")\n", &file).reads_external_resources(),
914            "`#bibliography` reads an external source file"
915        );
916    }
917
918    #[test]
919    fn lowerer_stamps_paired_label_span_covering_the_label_token() {
920        // Contract `mos-lsp` go-to-definition depends on (#101, hardened by
921        // #103): a labelled declaration carries BOTH `label_span.start` and
922        // `label_span.end` as `Int`s spanning exactly the label token, so the
923        // LSP can target `<intro>` rather than the whole heading. If the
924        // lowerer ever stamps one attribute without the other, or stops
925        // stamping them: `definition.rs`'s safe fallback would silently widen
926        // the definition range with no test catching it. This locks the pair.
927        let src = "= Intro <intro>\n";
928        let r = lower(src, &PathBuf::from("test.mos"));
929        assert!(!r.has_errors(), "{:?}", r.diagnostics);
930
931        let section = r
932            .document
933            .nodes()
934            .find(|node| {
935                node.kind == NodeKind::Section
936                    && node.attributes.get("label") == Some(&AttrValue::Str("intro".to_owned()))
937            })
938            .expect("a Section node carrying label `intro`");
939
940        let (Some(&AttrValue::Int(start)), Some(&AttrValue::Int(end))) = (
941            section.attributes.get(LABEL_SPAN_START_ATTR),
942            section.attributes.get(LABEL_SPAN_END_ATTR),
943        ) else {
944            panic!(
945                "expected paired Int `label_span.*` attrs, got {:?} / {:?}",
946                section.attributes.get(LABEL_SPAN_START_ATTR),
947                section.attributes.get(LABEL_SPAN_END_ATTR),
948            );
949        };
950
951        let start = usize::try_from(start).expect("label_span.start fits usize");
952        let end = usize::try_from(end).expect("label_span.end fits usize");
953        assert!(
954            start <= end,
955            "label span start {start} must not exceed end {end}"
956        );
957        assert_eq!(
958            src.get(start..end),
959            Some("intro"),
960            "label span must cover exactly the `intro` token, not the whole heading"
961        );
962    }
963
964    #[test]
965    fn reference_nodes_carry_stamped_label_span() {
966        // Issue #116: lowered `@label` / `@page(label)` reference nodes carry
967        // the same paired `label_span.*` identifier attributes as declarations,
968        // so `mos-lsp` rename reads the editable range directly instead of
969        // deriving it from the reference node's span geometry.
970        let src = "= Intro <intro>\n\nsee @intro and @page(intro)\n";
971        let r = lower(src, &PathBuf::from("test.mos"));
972        assert!(!r.has_errors(), "{:?}", r.diagnostics);
973
974        for kind in [NodeKind::Reference, NodeKind::PageReference] {
975            let node = r
976                .document
977                .nodes()
978                .find(|node| node.kind == kind)
979                .unwrap_or_else(|| panic!("expected a {kind:?} node"));
980            let (Some(&AttrValue::Int(start)), Some(&AttrValue::Int(end))) = (
981                node.attributes.get(LABEL_SPAN_START_ATTR),
982                node.attributes.get(LABEL_SPAN_END_ATTR),
983            ) else {
984                panic!(
985                    "{kind:?} node missing paired `label_span.*`: {:?}",
986                    node.attributes
987                );
988            };
989            let start = usize::try_from(start).expect("label_span.start fits usize");
990            let end = usize::try_from(end).expect("label_span.end fits usize");
991            assert_eq!(
992                src.get(start..end),
993                Some("intro"),
994                "{kind:?} label span must cover exactly the `intro` identifier"
995            );
996        }
997    }
998
999    #[test]
1000    fn lowers_heading_and_paragraph() {
1001        let r = lower(
1002            "= Hello\n\nbody *italic* text\n",
1003            &PathBuf::from("test.mos"),
1004        );
1005        assert!(!r.has_errors());
1006        // Document root + Section + Paragraph + 1 Text inside Section
1007        // + 3 inline children of Paragraph (text/emphasis/text).
1008        assert_eq!(r.document.len(), 1 + 2 + 1 + 3);
1009
1010        let kinds: Vec<NodeKind> = r.document.nodes().map(|n| n.kind).collect();
1011        assert_eq!(kinds[0], NodeKind::Document);
1012        assert!(kinds.contains(&NodeKind::Section));
1013        assert!(kinds.contains(&NodeKind::Paragraph));
1014        assert!(kinds.contains(&NodeKind::Emphasis));
1015    }
1016
1017    #[test]
1018    fn lowers_nested_bold_italic_inline() {
1019        let r = lower("***both***\n", &PathBuf::from("test.mos"));
1020        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1021        assert!(
1022            r.document.nodes().any(|n| n.kind == NodeKind::BoldItalic),
1023            "expected bold-italic node in {:?}",
1024            r.document.nodes().map(|n| n.kind).collect::<Vec<_>>()
1025        );
1026    }
1027
1028    #[test]
1029    fn root_owns_top_level_items() {
1030        let r = lower("= A\n\n= B\n\npara\n", &PathBuf::from("test.mos"));
1031        let root = r.document.get(r.document.root).unwrap();
1032        assert_eq!(root.children.len(), 3);
1033    }
1034
1035    #[test]
1036    fn hard_break_lowers_to_hardbreak_node_without_text_attr() {
1037        let r = lower("foo\\\\bar\n", &PathBuf::from("test.mos"));
1038        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1039
1040        // The paragraph is the second top-level node (after the
1041        // document root). Find its children.
1042        let root = r.document.get(r.document.root).unwrap();
1043        let paragraph_id = *root.children.first().unwrap();
1044        let paragraph = r.document.get(paragraph_id).unwrap();
1045        let inline_kinds: Vec<NodeKind> = paragraph
1046            .children
1047            .iter()
1048            .filter_map(|id| r.document.get(*id).map(|n| n.kind))
1049            .collect();
1050        assert_eq!(
1051            inline_kinds,
1052            vec![NodeKind::Text, NodeKind::HardBreak, NodeKind::Text],
1053            "got {inline_kinds:?}"
1054        );
1055
1056        // The HardBreak node must have no `text` attribute -- layout
1057        // dispatch matches on kind, not on text presence.
1058        let hardbreak_id = paragraph.children[1];
1059        let hardbreak = r.document.get(hardbreak_id).unwrap();
1060        assert!(
1061            hardbreak.attributes.is_empty(),
1062            "expected empty attribute map on HardBreak, got {:?}",
1063            hardbreak.attributes
1064        );
1065    }
1066
1067    /// Hand-craft a tiny PNG in a temp dir so the eval tests don't
1068    /// depend on `examples/` paths or the workspace layout.
1069    /// `::image::` (rather than `image::`) routes through the extern
1070    /// `image` crate; the bare `image` identifier inside the eval
1071    /// crate resolves to the local `mod image` we declared up top.
1072    fn write_tiny_png(name: &str) -> PathBuf {
1073        let dir = std::env::temp_dir().join(format!(
1074            "mos-eval-image-{}-{}",
1075            name,
1076            std::time::SystemTime::now()
1077                .duration_since(std::time::UNIX_EPOCH)
1078                .map_or(0, |d| d.as_nanos())
1079        ));
1080        std::fs::create_dir_all(&dir).unwrap();
1081        let path = dir.join(name);
1082        let mut buf = ::image::RgbaImage::new(3, 2);
1083        for x in 0_u32..3 {
1084            for y in 0_u32..2 {
1085                let r = u8::try_from(x * 80).unwrap_or(0);
1086                let g = u8::try_from(y * 120).unwrap_or(0);
1087                buf.put_pixel(x, y, ::image::Rgba([r, g, 200, 255]));
1088            }
1089        }
1090        buf.save(&path).unwrap();
1091        path
1092    }
1093
1094    #[test]
1095    fn image_directive_attaches_decoded_pixels() {
1096        let png_path = write_tiny_png("tiny.png");
1097        let source = png_path.parent().unwrap().join("main.mos");
1098        std::fs::write(&source, "#image(\"tiny.png\")\n").unwrap();
1099        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1100        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1101        let image_node = r
1102            .document
1103            .nodes()
1104            .find(|n| n.kind == NodeKind::Image)
1105            .expect("Image node");
1106        assert_eq!(
1107            image_node.attributes.get("src"),
1108            Some(&AttrValue::Str("tiny.png".to_owned()))
1109        );
1110        assert_eq!(
1111            image_node.attributes.get("pixel_width"),
1112            Some(&AttrValue::Int(3))
1113        );
1114        assert_eq!(
1115            image_node.attributes.get("pixel_height"),
1116            Some(&AttrValue::Int(2))
1117        );
1118        match image_node.attributes.get("pixels") {
1119            Some(AttrValue::Bytes(b)) => assert_eq!(b.len(), 3 * 3 * 2),
1120            other => panic!("expected pixel bytes, got {other:?}"),
1121        }
1122        std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1123    }
1124
1125    #[test]
1126    fn image_directive_records_explicit_dimensions() {
1127        let png_path = write_tiny_png("dims.png");
1128        let source = png_path.parent().unwrap().join("main.mos");
1129        std::fs::write(
1130            &source,
1131            "#image(\"dims.png\", width: 100pt, height: 60pt, alt: \"a tiny image\")\n",
1132        )
1133        .unwrap();
1134        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1135        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1136        let image_node = r
1137            .document
1138            .nodes()
1139            .find(|n| n.kind == NodeKind::Image)
1140            .expect("Image node");
1141        assert_eq!(
1142            image_node.attributes.get("width"),
1143            Some(&AttrValue::Length(100.0))
1144        );
1145        assert_eq!(
1146            image_node.attributes.get("height"),
1147            Some(&AttrValue::Length(60.0))
1148        );
1149        assert_eq!(
1150            image_node.attributes.get("alt"),
1151            Some(&AttrValue::Str("a tiny image".to_owned()))
1152        );
1153        std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1154    }
1155
1156    #[test]
1157    fn image_em_width_resolves_against_current_text_size() {
1158        // Regression: `#image(width: 2em)` after `#set text(size: 20pt)`
1159        // must yield 40pt, not 22pt (which is what the old hardcoded
1160        // 11pt em base produced). The lowerer now threads the tracked
1161        // body text size through to `build_image_attributes`.
1162        let png_path = write_tiny_png("em.png");
1163        let dir = png_path.parent().unwrap();
1164        let source = dir.join("main.mos");
1165        std::fs::write(
1166            &source,
1167            "#set text(size: 20pt)\n#image(\"em.png\", width: 2em)\n",
1168        )
1169        .unwrap();
1170        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1171        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1172        let image_node = r
1173            .document
1174            .nodes()
1175            .find(|n| n.kind == NodeKind::Image)
1176            .expect("Image node");
1177        match image_node.attributes.get("width") {
1178            Some(AttrValue::Length(pt)) => assert!(
1179                (pt - 40.0).abs() < 0.01,
1180                "width = {pt}pt, expected 40pt (2em at 20pt)"
1181            ),
1182            other => panic!("expected width Length, got {other:?}"),
1183        }
1184        std::fs::remove_dir_all(dir).ok();
1185    }
1186
1187    #[test]
1188    fn missing_image_path_emits_mos0037() {
1189        let r = lower("#image()\n", &PathBuf::from("/tmp/no-such.mos"));
1190        assert!(
1191            r.diagnostics
1192                .iter()
1193                .any(|d| d.def().code() == codes::MOS0037.code()),
1194            "expected MOS0037, got {:?}",
1195            r.diagnostics
1196        );
1197    }
1198
1199    #[test]
1200    fn unreadable_image_emits_mos0012() {
1201        let r = lower(
1202            "#image(\"does-not-exist.png\")\n",
1203            &PathBuf::from("/tmp/no-such-dir/main.mos"),
1204        );
1205        assert!(
1206            r.diagnostics
1207                .iter()
1208                .any(|d| d.def().code() == codes::MOS0012.code()),
1209            "expected MOS0012, got {:?}",
1210            r.diagnostics
1211        );
1212    }
1213
1214    #[test]
1215    fn empty_image_path_emits_mos0037_not_io_error() {
1216        // `#image("")` is a missing-path mistake, not an I/O failure.
1217        // The diagnostic surface treats it the same as omitting the
1218        // path entirely so the user sees a clear "needs a path"
1219        // message instead of `MOS0012`/`MOS0029` noise.
1220        let r = lower("#image(\"\")\n", &PathBuf::from("/tmp/whatever/main.mos"));
1221        assert!(
1222            r.diagnostics
1223                .iter()
1224                .any(|d| d.def().code() == codes::MOS0037.code()),
1225            "expected MOS0037, got {:?}",
1226            r.diagnostics
1227        );
1228        // No MOS0012/MOS0029 should leak through.
1229        assert!(
1230            !r.diagnostics.iter().any(|d| {
1231                d.def().code() == codes::MOS0012.code() || d.def().code() == codes::MOS0029.code()
1232            }),
1233            "unexpected I/O diagnostic: {:?}",
1234            r.diagnostics
1235        );
1236    }
1237
1238    #[test]
1239    fn non_positive_image_width_emits_mos0020() {
1240        // `width: 0pt` and `width: -10pt` would otherwise produce a
1241        // zero/negative image box that sails into layout and PDF
1242        // emit. Reject at lower time with MOS0020.
1243        for src in [
1244            "#image(\"x.png\", width: 0pt)\n",
1245            "#image(\"x.png\", width: -10pt)\n",
1246            "#image(\"x.png\", width: 0)\n",
1247            "#image(\"x.png\", width: -1)\n",
1248        ] {
1249            let r = lower(src, &PathBuf::from("/tmp/whatever/main.mos"));
1250            assert!(
1251                r.diagnostics
1252                    .iter()
1253                    .any(|d| d.def().code() == codes::MOS0020.code()),
1254                "expected MOS0020 for `{src}`, got {:?}",
1255                r.diagnostics
1256            );
1257        }
1258    }
1259
1260    #[test]
1261    fn non_positive_image_height_emits_mos0020() {
1262        for src in [
1263            "#image(\"x.png\", height: 0pt)\n",
1264            "#image(\"x.png\", height: -1mm)\n",
1265        ] {
1266            let r = lower(src, &PathBuf::from("/tmp/whatever/main.mos"));
1267            assert!(
1268                r.diagnostics
1269                    .iter()
1270                    .any(|d| d.def().code() == codes::MOS0020.code()),
1271                "expected MOS0020 for `{src}`, got {:?}",
1272                r.diagnostics
1273            );
1274        }
1275    }
1276
1277    #[test]
1278    fn undecodable_image_emits_mos0029() {
1279        let dir = std::env::temp_dir().join(format!(
1280            "mos-eval-bad-{}",
1281            std::time::SystemTime::now()
1282                .duration_since(std::time::UNIX_EPOCH)
1283                .map_or(0, |d| d.as_nanos())
1284        ));
1285        std::fs::create_dir_all(&dir).unwrap();
1286        let png = dir.join("bad.png");
1287        std::fs::write(&png, b"not really a PNG").unwrap();
1288        let source = dir.join("main.mos");
1289        std::fs::write(&source, "#image(\"bad.png\")\n").unwrap();
1290        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1291        assert!(
1292            r.diagnostics
1293                .iter()
1294                .any(|d| d.def().code() == codes::MOS0029.code()),
1295            "expected MOS0029, got {:?}",
1296            r.diagnostics
1297        );
1298        std::fs::remove_dir_all(&dir).ok();
1299    }
1300
1301    #[test]
1302    fn figure_directive_creates_figure_with_image_and_caption() {
1303        let png_path = write_tiny_png("fig.png");
1304        let source = png_path.parent().unwrap().join("main.mos");
1305        std::fs::write(
1306            &source,
1307            "#figure(image: \"fig.png\", caption: \"A tiny picture.\")\n",
1308        )
1309        .unwrap();
1310        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1311        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1312        let figure = r
1313            .document
1314            .nodes()
1315            .find(|n| n.kind == NodeKind::Figure)
1316            .expect("Figure node");
1317        assert_eq!(figure.children.len(), 2);
1318        let img = r.document.get(figure.children[0]).unwrap();
1319        assert_eq!(img.kind, NodeKind::Image);
1320        let caption = r.document.get(figure.children[1]).unwrap();
1321        assert_eq!(caption.kind, NodeKind::Paragraph);
1322        assert_eq!(
1323            caption.attributes.get("role"),
1324            Some(&AttrValue::Str("caption".to_owned()))
1325        );
1326        // `lower` runs the resolver, which numbers the figure and stamps
1327        // the non-breaking `Figure 1: ` supplement label onto the caption.
1328        let caption_text = r.document.get(caption.children[0]).unwrap();
1329        assert_eq!(
1330            caption_text.attributes.get("text"),
1331            Some(&AttrValue::Str(
1332                "Figure\u{00A0}1: A tiny picture.".to_owned()
1333            ))
1334        );
1335        std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1336    }
1337
1338    #[test]
1339    fn figure_with_missing_image_does_not_leak_empty_node() {
1340        // If `#figure(image: "broken.png", caption: "...")` fails to
1341        // load the image, the caller still emits MOS0012; the lowerer
1342        // must NOT leave a Figure (or its caption paragraph) hanging
1343        // on the document root. A caption-only figure renders next
1344        // to whatever the user thought they were captioning, which
1345        // is worse than no output for the failed block.
1346        let r = lower(
1347            "#figure(image: \"does-not-exist.png\", caption: \"missing\")\n",
1348            &PathBuf::from("/tmp/no-such-dir/main.mos"),
1349        );
1350        assert!(
1351            r.diagnostics
1352                .iter()
1353                .any(|d| d.def().code() == codes::MOS0012.code())
1354        );
1355        assert!(
1356            !r.document.nodes().any(|n| n.kind == NodeKind::Figure),
1357            "Figure node leaked after image load failure",
1358        );
1359    }
1360
1361    #[test]
1362    fn figure_label_reference_resolves_to_figure_number() {
1363        // End-to-end: a real `#figure(label: ...)` lowers with its label
1364        // on the Figure node, the resolver numbers the figure, and an
1365        // `@label` reference rewrites to kind-aware `Figure 1` text. Note
1366        // the space before `here.`: a `.` flush against the reference
1367        // would be absorbed into the label (`fig:plot.`) and miss.
1368        let png_path = write_tiny_png("ref-fig.png");
1369        let dir = png_path.parent().unwrap();
1370        let source = dir.join("main.mos");
1371        std::fs::write(
1372            &source,
1373            "#figure(image: \"ref-fig.png\", caption: \"A plot.\", label: \"fig:plot\")\n\nSee @fig:plot here.\n",
1374        )
1375        .unwrap();
1376        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1377        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1378
1379        let figure = r
1380            .document
1381            .nodes()
1382            .find(|n| n.kind == NodeKind::Figure)
1383            .expect("Figure node");
1384        assert_eq!(
1385            figure.attributes.get("number"),
1386            Some(&AttrValue::Str("1".to_owned())),
1387            "the lowered figure is numbered in document order"
1388        );
1389        assert_eq!(
1390            figure.attributes.get("label"),
1391            Some(&AttrValue::Str("fig:plot".to_owned())),
1392            "the `label:` argument lands on the Figure node"
1393        );
1394
1395        // The caption text is stamped with the visible, non-breaking label.
1396        let caption_text = figure
1397            .children
1398            .iter()
1399            .filter_map(|c| r.document.get(*c))
1400            .find(|c| {
1401                matches!(c.attributes.get("role"), Some(AttrValue::Str(role)) if role == "caption")
1402            })
1403            .and_then(|caption| caption.children.first())
1404            .and_then(|text_id| r.document.get(*text_id))
1405            .and_then(|text| text.attributes.get("text"));
1406        assert_eq!(
1407            caption_text,
1408            Some(&AttrValue::Str("Figure\u{00A0}1: A plot.".to_owned())),
1409            "the caption is prefixed with the `Figure N: ` label"
1410        );
1411
1412        let reference = r
1413            .document
1414            .nodes()
1415            .find(|n| n.kind == NodeKind::Reference)
1416            .expect("Reference node");
1417        assert_eq!(
1418            reference.attributes.get("text"),
1419            Some(&AttrValue::Str("Figure\u{00A0}1".to_owned())),
1420            "the `@fig:plot` reference resolves to kind-aware figure text"
1421        );
1422
1423        std::fs::remove_dir_all(dir).ok();
1424    }
1425
1426    #[test]
1427    fn page_reference_lowers_to_inert_page_reference_node() {
1428        // `@page(label)` reaches the semantic model as a distinct
1429        // `NodeKind::PageReference` carrying the bare label and a `?label?`
1430        // placeholder (the unresolved-reference pattern). This slice models the
1431        // node but does not resolve it: page resolution is the resolve↔layout
1432        // fixpoint (issue #72), so it must NOT be folded into the cross-
1433        // reference machinery and the placeholder must survive lowering. The
1434        // label is declared so the lower-time validation stays quiet here.
1435        let r = lower(
1436            "= Intro <intro>\n\nSee @page(intro) here.\n",
1437            &PathBuf::from("test.mos"),
1438        );
1439        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1440
1441        let page_ref = r
1442            .document
1443            .nodes()
1444            .find(|n| n.kind == NodeKind::PageReference)
1445            .expect("PageReference node");
1446        assert_eq!(
1447            page_ref.attributes.get("label"),
1448            Some(&AttrValue::Str("intro".to_owned())),
1449        );
1450        assert_eq!(
1451            page_ref.attributes.get("text"),
1452            Some(&AttrValue::Str("?intro?".to_owned())),
1453            "unresolved page references keep a visible placeholder",
1454        );
1455        // A page reference is its own kind, not an `@label` cross-reference.
1456        assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1457    }
1458
1459    #[test]
1460    fn undeclared_page_reference_label_emits_mos0033() {
1461        // An undeclared label in `@page(...)` is a lower-time error, exactly
1462        // like a bad `@ref`: `mos check` reports it without laying out.
1463        let r = lower("See @page(missing) here.\n", &PathBuf::from("test.mos"));
1464        assert!(
1465            r.diagnostics
1466                .iter()
1467                .any(|d| d.def().code() == codes::MOS0033.code()),
1468            "{:?}",
1469            r.diagnostics
1470        );
1471        // The placeholder survives so the page reference stays visible.
1472        let page_ref = r
1473            .document
1474            .nodes()
1475            .find(|n| n.kind == NodeKind::PageReference)
1476            .expect("PageReference node");
1477        assert_eq!(
1478            page_ref.attributes.get("text"),
1479            Some(&AttrValue::Str("?missing?".to_owned())),
1480        );
1481    }
1482
1483    #[test]
1484    fn page_reference_to_a_declared_label_is_not_a_duplicate_declaration() {
1485        // A page reference *consumes* a label; it must not be mistaken for a
1486        // second declaration of `intro`, which would wrongly emit MOS0030.
1487        let r = lower(
1488            "= Intro <intro>\n\nSee @page(intro) here.\n",
1489            &PathBuf::from("test.mos"),
1490        );
1491        assert!(
1492            !r.diagnostics
1493                .iter()
1494                .any(|d| d.def().code() == codes::MOS0030.code()),
1495            "{:?}",
1496            r.diagnostics
1497        );
1498    }
1499
1500    #[test]
1501    fn citation_lowers_to_citation_node_with_key_and_span() {
1502        // `[@key]` must reach the semantic model as `NodeKind::Citation`
1503        // with the bare key in the `key` attribute and a span that
1504        // covers the full `[@key]` source extent. The placeholder
1505        // `text` attribute mirrors the unresolved-reference pattern
1506        // so layout still renders something visible before citation
1507        // display rendering exists.
1508        let src = "see [@smith2024] here\n";
1509        let r = lower(src, &PathBuf::from("test.mos"));
1510        assert!(
1511            r.diagnostics
1512                .iter()
1513                .any(|d| d.def().code() == codes::MOS0045.code()),
1514            "expected MOS0045 because no bibliography records are declared, got {:?}",
1515            r.diagnostics
1516        );
1517        let citation = r
1518            .document
1519            .nodes()
1520            .find(|n| n.kind == NodeKind::Citation)
1521            .expect("citation node");
1522        assert_eq!(
1523            citation.attributes.get("key"),
1524            Some(&AttrValue::Str("smith2024".to_owned())),
1525        );
1526        assert_eq!(
1527            citation.attributes.get("text"),
1528            Some(&AttrValue::Str("[?smith2024?]".to_owned())),
1529        );
1530        let span_text = &src[citation.span.start()..citation.span.end()];
1531        assert_eq!(span_text, "[@smith2024]");
1532    }
1533
1534    #[test]
1535    fn malformed_citation_does_not_create_citation_node() {
1536        // `[@]` with an empty key must surface as a parse warning
1537        // (MOS0039) and produce zero `NodeKind::Citation` nodes: the
1538        // semantic model only carries citations that parsed cleanly.
1539        let r = lower("look [@] here\n", &PathBuf::from("test.mos"));
1540        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1541        assert!(
1542            r.diagnostics
1543                .iter()
1544                .any(|d| d.def().code() == codes::MOS0039.code()),
1545            "expected MOS0039, got {:?}",
1546            r.diagnostics,
1547        );
1548        assert!(
1549            !r.document.nodes().any(|n| n.kind == NodeKind::Citation),
1550            "no Citation nodes expected, got {:?}",
1551            r.document.nodes().map(|n| n.kind).collect::<Vec<_>>(),
1552        );
1553    }
1554
1555    #[test]
1556    fn unterminated_citation_does_not_leak_into_reference_resolver() {
1557        // Regression: an unterminated `[@key` used to advance just past
1558        // `[`, leaving `@key` to be re-tokenized by the `@`-reference
1559        // branch. The resolver then surfaced a bogus `MOS0033 unknown
1560        // label` on what was a citation typo, not a label typo.
1561        // Recovery in the parser now consumes the malformed citation
1562        // extent end-to-end so no phantom `Reference` reaches the
1563        // resolver.
1564        let r = lower(
1565            "see [@smith2024 missing close\n",
1566            &PathBuf::from("test.mos"),
1567        );
1568        assert!(
1569            !r.has_errors(),
1570            "no errors expected, got {:?}",
1571            r.diagnostics,
1572        );
1573        assert!(
1574            r.diagnostics
1575                .iter()
1576                .any(|d| d.def().code() == codes::MOS0039.code()),
1577            "expected MOS0039, got {:?}",
1578            r.diagnostics,
1579        );
1580        assert!(
1581            !r.diagnostics
1582                .iter()
1583                .any(|d| d.def().code() == codes::MOS0033.code()),
1584            "malformed citation must not surface as unknown-label MOS0033: {:?}",
1585            r.diagnostics,
1586        );
1587        assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Citation));
1588        assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1589    }
1590
1591    #[test]
1592    fn deferred_multi_key_citation_does_not_leak_into_reference_resolver() {
1593        // `[@a; @b]` is the pandoc multi-key form and is deferred to
1594        // a later bibliography slice. Until then it must round-trip
1595        // as a single `MOS0039` warning with zero `Citation`/`Reference`
1596        // nodes and zero `MOS0033` follow-on errors from the resolver.
1597        let r = lower(
1598            "compare [@smith2024; @jones2025] now\n",
1599            &PathBuf::from("test.mos"),
1600        );
1601        assert!(
1602            !r.has_errors(),
1603            "no errors expected, got {:?}",
1604            r.diagnostics,
1605        );
1606        assert!(
1607            r.diagnostics
1608                .iter()
1609                .any(|d| d.def().code() == codes::MOS0039.code())
1610        );
1611        assert!(
1612            !r.diagnostics
1613                .iter()
1614                .any(|d| d.def().code() == codes::MOS0033.code()),
1615            "multi-key citation must not surface as unknown-label MOS0033: {:?}",
1616            r.diagnostics,
1617        );
1618        assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Citation));
1619        assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Reference));
1620    }
1621
1622    #[test]
1623    fn figure_directive_accepts_positional_path() {
1624        // `#figure("path.png")` is the captionless short form. The
1625        // parser accepts it; the lowerer used to reject it with MOS0024,
1626        // which broke the spelling end-to-end.
1627        let png_path = write_tiny_png("fig_pos.png");
1628        let source = png_path.parent().unwrap().join("main.mos");
1629        std::fs::write(&source, "#figure(\"fig_pos.png\")\n").unwrap();
1630        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1631        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1632        let figure = r
1633            .document
1634            .nodes()
1635            .find(|n| n.kind == NodeKind::Figure)
1636            .expect("Figure node");
1637        // One child: just the image (no caption was supplied).
1638        assert_eq!(figure.children.len(), 1);
1639        let img = r.document.get(figure.children[0]).unwrap();
1640        assert_eq!(img.kind, NodeKind::Image);
1641        assert_eq!(
1642            img.attributes.get("src"),
1643            Some(&AttrValue::Str("fig_pos.png".to_owned()))
1644        );
1645        std::fs::remove_dir_all(png_path.parent().unwrap()).ok();
1646    }
1647
1648    /// Create a unique temp dir for a bibliography test. Salted with the
1649    /// caller's `name` plus a high-resolution timestamp so parallel tests
1650    /// don't collide, mirroring `write_tiny_png`.
1651    fn unique_temp_dir(name: &str) -> PathBuf {
1652        let dir = std::env::temp_dir().join(format!(
1653            "mos-eval-bib-{}-{}",
1654            name,
1655            std::time::SystemTime::now()
1656                .duration_since(std::time::UNIX_EPOCH)
1657                .map_or(0, |d| d.as_nanos())
1658        ));
1659        std::fs::create_dir_all(&dir).unwrap();
1660        dir
1661    }
1662
1663    #[test]
1664    fn bibliography_directive_preserves_resolved_path() {
1665        // A declared `#bibliography("refs.bib")` lowers to a Bibliography
1666        // node that preserves both the literal `src` and the path resolved
1667        // against the source file's directory, so the later BibTeX reader
1668        // can open the database. With the file present there is no warning.
1669        let dir = unique_temp_dir("preserve");
1670        let bib = dir.join("refs.bib");
1671        std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1672        let source = dir.join("main.mos");
1673        std::fs::write(&source, "#bibliography(\"refs.bib\")\n").unwrap();
1674        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1675        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1676        let node = r
1677            .document
1678            .nodes()
1679            .find(|n| n.kind == NodeKind::Bibliography)
1680            .expect("Bibliography node");
1681        assert_eq!(
1682            node.attributes.get("src"),
1683            Some(&AttrValue::Str("refs.bib".to_owned()))
1684        );
1685        assert_eq!(
1686            node.attributes.get("resolved_path"),
1687            Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1688        );
1689        std::fs::remove_dir_all(&dir).ok();
1690    }
1691
1692    #[test]
1693    fn lowering_exposes_the_loaded_bibliography_records() {
1694        let dir = unique_temp_dir("records");
1695        std::fs::write(dir.join("a.bib"), "@book{alpha, title={A}}\n").unwrap();
1696        std::fs::write(dir.join("b.bib"), "@article{beta, title={B}}\n").unwrap();
1697        let source = dir.join("main.mos");
1698        let r = lower(
1699            "#bibliography(\"a.bib\")\n\n#bibliography(\"b.bib\")\n\n#bibliography(\"missing.bib\")\n",
1700            &source,
1701        );
1702        let keys: Vec<&str> = r.bibliography.entries.keys().map(String::as_str).collect();
1703        assert_eq!(keys, ["alpha", "beta"]);
1704        assert_eq!(r.bibliography.entries["beta"].entry_type, "article");
1705        assert!(!r.bibliography_complete);
1706        assert!(
1707            r.diagnostics
1708                .iter()
1709                .any(|d| d.def().code() == codes::MOS0041.code()),
1710            "{:?}",
1711            r.diagnostics
1712        );
1713        std::fs::write(dir.join("missing.bib"), "@book{gamma, title={C}}\n").unwrap();
1714        let complete = lower(
1715            "#bibliography(\"a.bib\")\n\n#bibliography(\"b.bib\")\n\n#bibliography(\"missing.bib\")\n",
1716            &source,
1717        );
1718        assert!(complete.bibliography_complete);
1719        assert_eq!(complete.bibliography.entries.len(), 3);
1720        std::fs::remove_dir_all(&dir).ok();
1721    }
1722
1723    #[test]
1724    fn bibliography_named_path_resolves_against_source_dir() {
1725        // The named `path:` form resolves a subdirectory-relative path the
1726        // same way, exercising project-relative resolution explicitly.
1727        let dir = unique_temp_dir("named");
1728        let sub = dir.join("sources");
1729        std::fs::create_dir_all(&sub).unwrap();
1730        let bib = sub.join("refs.bib");
1731        std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1732        let source = dir.join("main.mos");
1733        std::fs::write(&source, "#bibliography(path: \"sources/refs.bib\")\n").unwrap();
1734        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1735        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1736        let node = r
1737            .document
1738            .nodes()
1739            .find(|n| n.kind == NodeKind::Bibliography)
1740            .expect("Bibliography node");
1741        assert_eq!(
1742            node.attributes.get("resolved_path"),
1743            Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1744        );
1745        std::fs::remove_dir_all(&dir).ok();
1746    }
1747
1748    #[test]
1749    fn bibliography_src_alias_resolves_against_source_dir() {
1750        // The `src:` alias is accepted for parity with image source naming,
1751        // and preserves the literal source path for the later BibTeX reader.
1752        let dir = unique_temp_dir("src-alias");
1753        let sub = dir.join("sources");
1754        std::fs::create_dir_all(&sub).unwrap();
1755        let bib = sub.join("refs.bib");
1756        std::fs::write(&bib, "@book{a, title={A}}\n").unwrap();
1757        let source = dir.join("main.mos");
1758        std::fs::write(&source, "#bibliography(src: \"sources/refs.bib\")\n").unwrap();
1759        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1760        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1761        let node = r
1762            .document
1763            .nodes()
1764            .find(|n| n.kind == NodeKind::Bibliography)
1765            .expect("Bibliography node");
1766        assert_eq!(
1767            node.attributes.get("src"),
1768            Some(&AttrValue::Str("sources/refs.bib".to_owned()))
1769        );
1770        assert_eq!(
1771            node.attributes.get("resolved_path"),
1772            Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1773        );
1774        std::fs::remove_dir_all(&dir).ok();
1775    }
1776
1777    #[test]
1778    fn known_citation_key_resolves_against_bibliography_records() {
1779        // A citation key declared in the parsed BibTeX source is marked
1780        // resolved and its visible text is rewritten to its first-use
1781        // numeric label `[1]` (issue #67).
1782        let dir = unique_temp_dir("citation-known");
1783        let bib = dir.join("refs.bib");
1784        std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
1785        let source = dir.join("main.mos");
1786        let source_text =
1787            "#bibliography(\"refs.bib\")\n\n= Intro <intro>\n\nsee [@smith2024] and @intro\n";
1788        std::fs::write(&source, source_text).unwrap();
1789
1790        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1791        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1792
1793        let citation = r
1794            .document
1795            .nodes()
1796            .find(|n| n.kind == NodeKind::Citation)
1797            .expect("Citation node");
1798        assert_eq!(
1799            citation.attributes.get("resolved"),
1800            Some(&AttrValue::Bool(true)),
1801            "known key should be marked resolved for later rendering"
1802        );
1803        assert_eq!(
1804            citation.attributes.get("text"),
1805            Some(&AttrValue::Str("[1]".to_owned())),
1806            "a resolved citation renders its first-use numeric label"
1807        );
1808        assert_eq!(
1809            citation.attributes.get("target_path"),
1810            Some(&AttrValue::Str(bib.to_string_lossy().into_owned()))
1811        );
1812        assert_eq!(
1813            citation.attributes.get("target_span.start"),
1814            Some(&AttrValue::Int(9))
1815        );
1816        assert_eq!(
1817            citation.attributes.get("target_span.end"),
1818            Some(&AttrValue::Int(18))
1819        );
1820
1821        let reference = r
1822            .document
1823            .nodes()
1824            .find(|n| n.kind == NodeKind::Reference)
1825            .expect("Reference node");
1826        assert_eq!(
1827            reference.attributes.get("text"),
1828            Some(&AttrValue::Str("1".to_owned())),
1829            "label references still resolve while citations are checked"
1830        );
1831
1832        std::fs::remove_dir_all(&dir).ok();
1833    }
1834
1835    /// The `(entry_number, entry_key, rendered text)` triples attached to
1836    /// the first Bibliography node, in child order.
1837    fn bibliography_entries(document: &Document) -> Vec<(i64, String, String)> {
1838        let bib = document
1839            .nodes()
1840            .find(|n| n.kind == NodeKind::Bibliography)
1841            .expect("Bibliography node");
1842        bib.children
1843            .iter()
1844            .filter_map(|id| document.get(*id))
1845            .map(|entry| {
1846                let Some(AttrValue::Int(number)) = entry.attributes.get("entry_number") else {
1847                    panic!("entry paragraph without entry_number: {entry:?}");
1848                };
1849                let Some(AttrValue::Str(key)) = entry.attributes.get("entry_key") else {
1850                    panic!("entry paragraph without entry_key: {entry:?}");
1851                };
1852                let text = entry
1853                    .children
1854                    .iter()
1855                    .filter_map(|id| document.get(*id))
1856                    .find_map(|child| match child.attributes.get("text") {
1857                        Some(AttrValue::Str(text)) => Some(text.clone()),
1858                        _ => None,
1859                    })
1860                    .expect("entry paragraph without a Text child");
1861                (*number, key.clone(), text)
1862            })
1863            .collect()
1864    }
1865
1866    #[test]
1867    fn cited_entries_render_in_first_use_order() {
1868        // Citing `[@b]` before `[@a]` numbers b=1, a=2; the bibliography
1869        // node's entry children follow that first-use order, not BibTeX
1870        // declaration order (issue #112).
1871        let dir = unique_temp_dir("entries-order");
1872        std::fs::write(
1873            dir.join("refs.bib"),
1874            "@book{a, title={Alpha}, author={Ada Author}, year={1990}}\n\
1875             @book{b, title={Beta}, publisher={Beta House}, year={1991}}\n",
1876        )
1877        .unwrap();
1878        let source = dir.join("main.mos");
1879        std::fs::write(
1880            &source,
1881            "#bibliography(\"refs.bib\")\n\nsee [@b] then [@a]\n",
1882        )
1883        .unwrap();
1884
1885        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1886        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1887
1888        let entries = bibliography_entries(&r.document);
1889        assert_eq!(entries.len(), 2, "{entries:?}");
1890        assert_eq!(entries[0].0, 1);
1891        assert_eq!(entries[0].1, "b");
1892        assert_eq!(entries[0].2, "Beta. Beta House. 1991.");
1893        assert_eq!(entries[1].0, 2);
1894        assert_eq!(entries[1].1, "a");
1895        assert_eq!(entries[1].2, "Ada Author. Alpha. 1990.");
1896
1897        std::fs::remove_dir_all(&dir).ok();
1898    }
1899
1900    #[test]
1901    fn repeated_and_uncited_keys_render_one_entry_each_or_none() {
1902        // `[@a]` cited twice renders one entry; the never-cited `b` record
1903        // contributes nothing (uncited entries are out of scope for the
1904        // numeric slice).
1905        let dir = unique_temp_dir("entries-dedup");
1906        std::fs::write(
1907            dir.join("refs.bib"),
1908            "@book{a, title={Alpha}}\n@book{b, title={Beta}}\n",
1909        )
1910        .unwrap();
1911        let source = dir.join("main.mos");
1912        std::fs::write(
1913            &source,
1914            "#bibliography(\"refs.bib\")\n\nsee [@a] and [@a] again\n",
1915        )
1916        .unwrap();
1917
1918        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1919        assert!(!r.has_errors(), "{:?}", r.diagnostics);
1920
1921        let entries = bibliography_entries(&r.document);
1922        assert_eq!(entries.len(), 1, "{entries:?}");
1923        assert_eq!(entries[0], (1, "a".to_owned(), "Alpha.".to_owned()));
1924
1925        std::fs::remove_dir_all(&dir).ok();
1926    }
1927
1928    #[test]
1929    fn unresolved_keys_produce_no_bibliography_entries() {
1930        // An unknown key keeps its MOS0045 diagnostic and never occupies a
1931        // numbered entry slot.
1932        let dir = unique_temp_dir("entries-unresolved");
1933        std::fs::write(dir.join("refs.bib"), "@book{real, title={Real}}\n").unwrap();
1934        let source = dir.join("main.mos");
1935        std::fs::write(&source, "#bibliography(\"refs.bib\")\n\nsee [@phantom]\n").unwrap();
1936
1937        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
1938        assert!(
1939            r.diagnostics
1940                .iter()
1941                .any(|d| d.def().code() == codes::MOS0045.code()),
1942            "unknown key keeps MOS0045: {:?}",
1943            r.diagnostics
1944        );
1945
1946        let entries = bibliography_entries(&r.document);
1947        assert!(entries.is_empty(), "{entries:?}");
1948
1949        std::fs::remove_dir_all(&dir).ok();
1950    }
1951
1952    fn mos0049_diagnostic(diagnostics: &[Diagnostic]) -> &Diagnostic {
1953        let found: Vec<&Diagnostic> = diagnostics
1954            .iter()
1955            .filter(|d| d.def().code() == codes::MOS0049.code())
1956            .collect();
1957        assert_eq!(found.len(), 1, "exactly one MOS0049, got {found:?}");
1958        found[0]
1959    }
1960
1961    #[test]
1962    fn unsafe_named_image_and_bibliography_paths_suggest_forward_slashes() {
1963        let source_text = "#image(src: \"a\\\\b.png\")\n#bibliography(path: \"c\\\\d.bib\")\n";
1964        let r = lower(source_text, &PathBuf::from("test.mos"));
1965        let fixes: Vec<(String, &str)> = r
1966            .diagnostics
1967            .iter()
1968            .filter(|d| d.def().code() == codes::MOS0049.code())
1969            .flat_map(|d| d.suggestions().iter())
1970            .map(|s| {
1971                (
1972                    s.replacement.clone(),
1973                    &source_text[s.span.start()..s.span.end()],
1974                )
1975            })
1976            .collect();
1977        assert_eq!(
1978            fixes,
1979            vec![
1980                ("a/b.png".to_owned(), r"a\\b.png"),
1981                ("c/d.bib".to_owned(), r"c\\d.bib"),
1982            ]
1983        );
1984    }
1985
1986    #[test]
1987    fn unsafe_path_fix_re_escapes_the_literal_contents() {
1988        let source_text = "#image(\"a\\\"b\\\\c.png\")\n";
1989        let r = lower(source_text, &PathBuf::from("test.mos"));
1990        let diag = mos0049_diagnostic(&r.diagnostics);
1991        let suggestions = diag.suggestions();
1992        assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
1993        assert_eq!(suggestions[0].replacement, r#"a\"b/c.png"#);
1994        assert_eq!(
1995            &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
1996            r#"a\"b\\c.png"#
1997        );
1998    }
1999
2000    #[test]
2001    fn unsafe_image_path_suggests_forward_slashes() {
2002        let source_text = "#image(\"assets\\\\logo.png\")\n";
2003        let r = lower(source_text, &PathBuf::from("test.mos"));
2004        let diag = mos0049_diagnostic(&r.diagnostics);
2005        let suggestions = diag.suggestions();
2006        assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2007        assert_eq!(suggestions[0].replacement, "assets/logo.png");
2008        assert_eq!(
2009            &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2010            r"assets\\logo.png",
2011            "the fix replaces the literal's contents, quotes excluded"
2012        );
2013    }
2014
2015    #[test]
2016    fn unsafe_figure_image_path_suggests_forward_slashes() {
2017        let source_text = "#figure(image: \"img\\\\a.png\", caption: \"c\")\n";
2018        let r = lower(source_text, &PathBuf::from("test.mos"));
2019        let diag = mos0049_diagnostic(&r.diagnostics);
2020        let suggestions = diag.suggestions();
2021        assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2022        assert_eq!(suggestions[0].replacement, "img/a.png");
2023        assert_eq!(
2024            &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2025            r"img\\a.png"
2026        );
2027    }
2028
2029    #[test]
2030    fn unsafe_bibliography_path_suggests_forward_slashes() {
2031        let source_text = "#bibliography(\"refs\\\\x.bib\")\n";
2032        let r = lower(source_text, &PathBuf::from("test.mos"));
2033        let diag = mos0049_diagnostic(&r.diagnostics);
2034        let suggestions = diag.suggestions();
2035        assert_eq!(suggestions.len(), 1, "one path fix, got {suggestions:?}");
2036        assert_eq!(suggestions[0].replacement, "refs/x.bib");
2037        assert_eq!(
2038            &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2039            r"refs\\x.bib"
2040        );
2041    }
2042
2043    #[test]
2044    fn drive_prefixed_image_path_gets_no_suggestion() {
2045        let source_text = "#image(\"C:\\\\x.png\")\n";
2046        let r = lower(source_text, &PathBuf::from("test.mos"));
2047        let diag = mos0049_diagnostic(&r.diagnostics);
2048        assert!(
2049            diag.suggestions().is_empty(),
2050            "no guess for a drive-prefixed path, got {:?}",
2051            diag.suggestions()
2052        );
2053    }
2054
2055    #[test]
2056    fn label_reference_matching_bib_key_suggests_citation() {
2057        // `@smith2024` resolves to no label but exactly matches a bibliography
2058        // key -- the user meant the citation `[@smith2024]`. MOS0033 carries a
2059        // structured fix swapping `@smith2024` for `[@smith2024]`.
2060        let dir = unique_temp_dir("ref-is-bibkey");
2061        let bib = dir.join("refs.bib");
2062        std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2063        let source = dir.join("main.mos");
2064        let source_text = "#bibliography(\"refs.bib\")\n\nsee @smith2024 here\n";
2065        std::fs::write(&source, source_text).unwrap();
2066
2067        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2068
2069        let diag = r
2070            .diagnostics
2071            .iter()
2072            .find(|d| d.def().code() == codes::MOS0033.code())
2073            .expect("MOS0033 for the @smith2024 reference");
2074        let suggestions = diag.suggestions();
2075        assert_eq!(
2076            suggestions.len(),
2077            1,
2078            "one citation fix, got {suggestions:?}"
2079        );
2080        assert_eq!(suggestions[0].replacement, "[@smith2024]");
2081        assert_eq!(
2082            &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2083            "@smith2024",
2084            "the fix replaces the whole `@key` token, sigil included"
2085        );
2086
2087        std::fs::remove_dir_all(&dir).ok();
2088    }
2089
2090    #[test]
2091    fn repeated_known_citation_key_reuses_its_first_number() {
2092        // Two citations to the same resolved key render the same numeric
2093        // label -- a key is numbered once, on first use.
2094        let dir = unique_temp_dir("citation-repeat");
2095        let bib = dir.join("refs.bib");
2096        std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2097        let source = dir.join("main.mos");
2098        let source_text =
2099            "#bibliography(\"refs.bib\")\n\nsee [@smith2024] and again [@smith2024]\n";
2100        std::fs::write(&source, source_text).unwrap();
2101
2102        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2103        assert!(!r.has_errors(), "{:?}", r.diagnostics);
2104
2105        let labels: Vec<Option<AttrValue>> = r
2106            .document
2107            .nodes()
2108            .filter(|n| n.kind == NodeKind::Citation)
2109            .map(|n| n.attributes.get("text").cloned())
2110            .collect();
2111        assert_eq!(
2112            labels,
2113            vec![
2114                Some(AttrValue::Str("[1]".to_owned())),
2115                Some(AttrValue::Str("[1]".to_owned())),
2116            ],
2117            "repeated key reuses its first-use number"
2118        );
2119
2120        std::fs::remove_dir_all(&dir).ok();
2121    }
2122
2123    #[test]
2124    fn distinct_known_citation_keys_number_by_first_use_order() {
2125        // Distinct resolved keys are numbered by the order they are first
2126        // cited, independent of their order in the BibTeX source, and a
2127        // later repeat of an earlier key keeps that key's number.
2128        let dir = unique_temp_dir("citation-order");
2129        let bib = dir.join("refs.bib");
2130        // `alpha` precedes `beta` in the database file...
2131        std::fs::write(
2132            &bib,
2133            "@article{alpha, title={A}}\n@article{beta, title={B}}\n",
2134        )
2135        .unwrap();
2136        let source = dir.join("main.mos");
2137        // ...but `beta` is *cited* first, so beta -> [1] and alpha -> [2].
2138        let source_text = "#bibliography(\"refs.bib\")\n\nsee [@beta] then [@alpha] and [@beta]\n";
2139        std::fs::write(&source, source_text).unwrap();
2140
2141        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2142        assert!(!r.has_errors(), "{:?}", r.diagnostics);
2143
2144        let labels: Vec<Option<AttrValue>> = r
2145            .document
2146            .nodes()
2147            .filter(|n| n.kind == NodeKind::Citation)
2148            .map(|n| n.attributes.get("text").cloned())
2149            .collect();
2150        assert_eq!(
2151            labels,
2152            vec![
2153                Some(AttrValue::Str("[1]".to_owned())),
2154                Some(AttrValue::Str("[2]".to_owned())),
2155                Some(AttrValue::Str("[1]".to_owned())),
2156            ],
2157            "numbering follows first citation, not bibliography source order"
2158        );
2159
2160        std::fs::remove_dir_all(&dir).ok();
2161    }
2162
2163    #[test]
2164    fn unknown_citation_key_emits_mos0045_with_source_span() {
2165        let dir = unique_temp_dir("citation-unknown");
2166        let bib = dir.join("refs.bib");
2167        std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
2168        let source = dir.join("main.mos");
2169        let source_text = "#bibliography(\"refs.bib\")\n\nsee [@missing]\n";
2170        std::fs::write(&source, source_text).unwrap();
2171
2172        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2173        let missing: Vec<&Diagnostic> = r
2174            .diagnostics
2175            .iter()
2176            .filter(|d| d.def().code() == codes::MOS0045.code())
2177            .collect();
2178        assert_eq!(
2179            missing.len(),
2180            1,
2181            "expected one MOS0045, got {:?}",
2182            r.diagnostics
2183        );
2184        let diagnostic = missing[0];
2185        assert!(
2186            diagnostic.message().contains("`missing`"),
2187            "diagnostic should name missing citation key, got {:?}",
2188            diagnostic.message()
2189        );
2190        assert_eq!(
2191            diagnostic
2192                .span()
2193                .map(|span| &source_text[span.start()..span.end()]),
2194            Some("[@missing]"),
2195            "MOS0045 should point at the citation token"
2196        );
2197
2198        let citation = r
2199            .document
2200            .nodes()
2201            .find(|n| n.kind == NodeKind::Citation)
2202            .expect("Citation node");
2203        assert_eq!(
2204            citation.attributes.get("text"),
2205            Some(&AttrValue::Str("[?missing?]".to_owned())),
2206            "unknown citations keep visible placeholder text"
2207        );
2208        assert_eq!(citation.attributes.get("resolved"), None);
2209
2210        std::fs::remove_dir_all(&dir).ok();
2211    }
2212
2213    #[test]
2214    fn unknown_citation_key_suggests_nearest_loaded_key() {
2215        let dir = unique_temp_dir("citation-nearest-key");
2216        let bib = dir.join("refs.bib");
2217        std::fs::write(&bib, "@article{smith2024, title={Known}}\n").unwrap();
2218        let source = dir.join("main.mos");
2219        let source_text = "#bibliography(\"refs.bib\")\n\nsee [@smit2024]\n";
2220        std::fs::write(&source, source_text).unwrap();
2221
2222        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2223        let diagnostic = r
2224            .diagnostics
2225            .iter()
2226            .find(|d| d.def().code() == codes::MOS0045.code())
2227            .expect("MOS0045 for missing citation key");
2228        let suggestions = diagnostic.suggestions();
2229        assert_eq!(
2230            suggestions.len(),
2231            1,
2232            "expected one nearest-key suggestion, got {suggestions:?}"
2233        );
2234        assert_eq!(suggestions[0].replacement, "smith2024");
2235        assert_eq!(
2236            &source_text[suggestions[0].span.start()..suggestions[0].span.end()],
2237            "smit2024",
2238            "suggestion should replace only the citation key token"
2239        );
2240
2241        std::fs::remove_dir_all(&dir).ok();
2242    }
2243
2244    #[test]
2245    fn unknown_citation_key_tie_has_no_suggestion() {
2246        let dir = unique_temp_dir("citation-nearest-key-tie");
2247        let bib = dir.join("refs.bib");
2248        std::fs::write(&bib, "@article{abx, title={X}}\n@article{aby, title={Y}}\n").unwrap();
2249        let source = dir.join("main.mos");
2250        let source_text = "#bibliography(\"refs.bib\")\n\nsee [@abc]\n";
2251        std::fs::write(&source, source_text).unwrap();
2252
2253        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2254        let diagnostic = r
2255            .diagnostics
2256            .iter()
2257            .find(|d| d.def().code() == codes::MOS0045.code())
2258            .expect("MOS0045 for missing citation key");
2259        assert!(
2260            diagnostic.suggestions().is_empty(),
2261            "ties should not produce a guess: {:?}",
2262            diagnostic.suggestions()
2263        );
2264
2265        std::fs::remove_dir_all(&dir).ok();
2266    }
2267
2268    #[test]
2269    fn multiple_unknown_citations_emit_deterministic_mos0045_diagnostics() {
2270        let dir = unique_temp_dir("citation-multiple-unknown");
2271        let bib = dir.join("refs.bib");
2272        std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
2273        let source = dir.join("main.mos");
2274        let source_text = "#bibliography(\"refs.bib\")\n\nsee [@alpha] and [@beta]\n";
2275        std::fs::write(&source, source_text).unwrap();
2276
2277        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2278        let spans: Vec<&str> = r
2279            .diagnostics
2280            .iter()
2281            .filter(|d| d.def().code() == codes::MOS0045.code())
2282            .filter_map(|d| d.span().map(|span| &source_text[span.start()..span.end()]))
2283            .collect();
2284        assert_eq!(
2285            spans,
2286            vec!["[@alpha]", "[@beta]"],
2287            "unknown citation diagnostics should follow document order"
2288        );
2289
2290        std::fs::remove_dir_all(&dir).ok();
2291    }
2292
2293    #[test]
2294    fn incomplete_bibliography_sources_do_not_emit_false_missing_citations() {
2295        let dir = unique_temp_dir("citation-incomplete-bibliography");
2296        let bib = dir.join("refs.bib");
2297        std::fs::write(&bib, "@article{known, title={Known}}\n").unwrap();
2298        let source = dir.join("main.mos");
2299        let source_text = "#bibliography(\"refs.bib\")\n#bibliography(\"missing.bib\")\n\nsee [@known] and [@maybe-in-missing]\n";
2300        std::fs::write(&source, source_text).unwrap();
2301
2302        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2303        assert!(
2304            r.diagnostics
2305                .iter()
2306                .any(|d| d.def().code() == codes::MOS0041.code()),
2307            "expected missing bibliography source warning, got {:?}",
2308            r.diagnostics
2309        );
2310        assert!(
2311            !r.diagnostics
2312                .iter()
2313                .any(|d| d.def().code() == codes::MOS0045.code()),
2314            "incomplete bibliography set must not produce false MOS0045 diagnostics"
2315        );
2316
2317        let known = r
2318            .document
2319            .nodes()
2320            .filter(|n| n.kind == NodeKind::Citation)
2321            .find(|n| n.attributes.get("key") == Some(&AttrValue::Str("known".to_owned())))
2322            .expect("known citation node");
2323        assert_eq!(
2324            known.attributes.get("resolved"),
2325            Some(&AttrValue::Bool(true))
2326        );
2327
2328        std::fs::remove_dir_all(&dir).ok();
2329    }
2330
2331    #[test]
2332    fn duplicate_citation_keys_across_bibliography_sources_emit_mos0046() {
2333        let dir = unique_temp_dir("citation-duplicate-key");
2334        let first = dir.join("first.bib");
2335        let second = dir.join("second.bib");
2336        std::fs::write(&first, "@article{dup, title={First}}\n").unwrap();
2337        std::fs::write(&second, "@book{dup, title={Second}}\n").unwrap();
2338        let source = dir.join("main.mos");
2339        let source_text =
2340            "#bibliography(\"first.bib\")\n#bibliography(\"second.bib\")\n\nsee [@dup]\n";
2341        std::fs::write(&source, source_text).unwrap();
2342
2343        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2344        let duplicates: Vec<&Diagnostic> = r
2345            .diagnostics
2346            .iter()
2347            .filter(|d| d.def().code() == codes::MOS0046.code())
2348            .collect();
2349        assert_eq!(
2350            duplicates.len(),
2351            1,
2352            "expected one MOS0046, got {:?}",
2353            r.diagnostics
2354        );
2355        let diagnostic = duplicates[0];
2356        assert!(
2357            diagnostic.message().contains("`dup`"),
2358            "diagnostic should name duplicate citation key, got {:?}",
2359            diagnostic.message()
2360        );
2361        assert_eq!(
2362            diagnostic
2363                .span()
2364                .map(|span| &source_text[span.start()..span.end()]),
2365            Some("#bibliography(\"second.bib\")"),
2366            "duplicate should point at the later bibliography source"
2367        );
2368
2369        std::fs::remove_dir_all(&dir).ok();
2370    }
2371
2372    #[test]
2373    fn missing_bibliography_path_emits_mos0040() {
2374        // `#bibliography()` with no path is the same authoring mistake as
2375        // `#image()`: a hard error, and no node leaks into the document.
2376        let r = lower("#bibliography()\n", &PathBuf::from("/tmp/no-such.mos"));
2377        assert!(
2378            r.diagnostics
2379                .iter()
2380                .any(|d| d.def().code() == codes::MOS0040.code()),
2381            "expected MOS0040, got {:?}",
2382            r.diagnostics
2383        );
2384        assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Bibliography));
2385    }
2386
2387    #[test]
2388    fn empty_bibliography_path_emits_mos0040() {
2389        // `#bibliography("")` is a missing-path mistake, not an I/O failure;
2390        // it surfaces as MOS0040 and never reaches the filesystem check.
2391        let r = lower(
2392            "#bibliography(\"\")\n",
2393            &PathBuf::from("/tmp/whatever/main.mos"),
2394        );
2395        assert!(
2396            r.diagnostics
2397                .iter()
2398                .any(|d| d.def().code() == codes::MOS0040.code()),
2399            "expected MOS0040, got {:?}",
2400            r.diagnostics
2401        );
2402        assert!(
2403            !r.diagnostics
2404                .iter()
2405                .any(|d| d.def().code() == codes::MOS0041.code()),
2406            "empty path must not trip the filesystem warning: {:?}",
2407            r.diagnostics
2408        );
2409    }
2410
2411    #[test]
2412    fn non_string_bibliography_path_emits_type_mismatch_only() {
2413        // A path-shaped arg with the wrong type is not "missing"; report
2414        // the type mismatch once and do not also emit missing-path/I/O noise.
2415        let r = lower(
2416            "#bibliography(src: 12pt)\n",
2417            &PathBuf::from("/tmp/whatever/main.mos"),
2418        );
2419        assert!(
2420            r.diagnostics
2421                .iter()
2422                .any(|d| d.def().code() == codes::MOS0020.code()),
2423            "expected MOS0020, got {:?}",
2424            r.diagnostics
2425        );
2426        assert!(
2427            !r.diagnostics
2428                .iter()
2429                .any(|d| d.def().code() == codes::MOS0040.code()),
2430            "non-string path must not also emit MOS0040: {:?}",
2431            r.diagnostics
2432        );
2433        assert!(
2434            !r.diagnostics
2435                .iter()
2436                .any(|d| d.def().code() == codes::MOS0041.code()),
2437            "non-string path must not reach filesystem warning: {:?}",
2438            r.diagnostics
2439        );
2440        assert!(!r.document.nodes().any(|n| n.kind == NodeKind::Bibliography));
2441    }
2442
2443    #[test]
2444    fn duplicate_bibliography_path_keeps_first_path() {
2445        // Duplicate path declarations are an authoring error, but the first
2446        // source still wins so later accidental args cannot silently redirect
2447        // the bibliography boundary.
2448        let dir = unique_temp_dir("duplicate-path");
2449        let first = dir.join("first.bib");
2450        let second = dir.join("second.bib");
2451        std::fs::write(&first, "@book{{first}}\n").unwrap();
2452        std::fs::write(&second, "@book{{second}}\n").unwrap();
2453        let source = dir.join("main.mos");
2454        let source_text = "#bibliography(\"first.bib\", path: \"second.bib\")\n";
2455        std::fs::write(&source, source_text).unwrap();
2456        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2457        let duplicate_path_diagnostics: Vec<&Diagnostic> = r
2458            .diagnostics
2459            .iter()
2460            .filter(|d| d.def().code() == codes::MOS0042.code())
2461            .collect();
2462        assert_eq!(
2463            duplicate_path_diagnostics.len(),
2464            1,
2465            "expected one MOS0042, got {:?}",
2466            r.diagnostics
2467        );
2468        let duplicate = duplicate_path_diagnostics[0];
2469        assert_eq!(
2470            duplicate
2471                .span()
2472                .map(|span| &source_text[span.start()..span.end()]),
2473            Some("\"second.bib\""),
2474            "duplicate path diagnostic should point at the later path value"
2475        );
2476        let node = r
2477            .document
2478            .nodes()
2479            .find(|n| n.kind == NodeKind::Bibliography)
2480            .expect("Bibliography node");
2481        assert_eq!(
2482            node.attributes.get("src"),
2483            Some(&AttrValue::Str("first.bib".to_owned()))
2484        );
2485        assert_eq!(
2486            node.attributes.get("resolved_path"),
2487            Some(&AttrValue::Str(first.to_string_lossy().into_owned()))
2488        );
2489        std::fs::remove_dir_all(&dir).ok();
2490    }
2491
2492    #[test]
2493    fn missing_bibliography_source_warns_mos0041_but_keeps_node() {
2494        // A declared-but-absent database is a non-fatal warning: the build
2495        // still succeeds and the node is emitted with its resolved path so
2496        // the later BibTeX slice can act on it.
2497        let dir = unique_temp_dir("absent");
2498        let source = dir.join("main.mos");
2499        std::fs::write(&source, "#bibliography(\"nope.bib\")\n").unwrap();
2500        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2501        assert!(
2502            !r.has_errors(),
2503            "a missing source is a warning, not an error: {:?}",
2504            r.diagnostics
2505        );
2506        assert!(
2507            r.diagnostics
2508                .iter()
2509                .any(|d| d.def().code() == codes::MOS0041.code()),
2510            "expected MOS0041, got {:?}",
2511            r.diagnostics
2512        );
2513        let node = r
2514            .document
2515            .nodes()
2516            .find(|n| n.kind == NodeKind::Bibliography)
2517            .expect("Bibliography node still emitted on a missing source");
2518        assert_eq!(
2519            node.attributes.get("resolved_path"),
2520            Some(&AttrValue::Str(
2521                dir.join("nope.bib").to_string_lossy().into_owned()
2522            ))
2523        );
2524        std::fs::remove_dir_all(&dir).ok();
2525    }
2526
2527    #[test]
2528    fn unknown_bibliography_arg_emits_mos0015() {
2529        // Arguments beyond the path (e.g. a future `style:`) are rejected
2530        // now so the directive's surface stays narrow until later slices
2531        // grow it deliberately.
2532        let dir = unique_temp_dir("unknownarg");
2533        std::fs::write(dir.join("refs.bib"), "@book{a}\n").unwrap();
2534        let source = dir.join("main.mos");
2535        std::fs::write(&source, "#bibliography(\"refs.bib\", style: \"ieee\")\n").unwrap();
2536        let r = lower(&std::fs::read_to_string(&source).unwrap(), &source);
2537        assert!(
2538            r.diagnostics
2539                .iter()
2540                .any(|d| d.def().code() == codes::MOS0015.code()),
2541            "expected MOS0015, got {:?}",
2542            r.diagnostics
2543        );
2544        std::fs::remove_dir_all(&dir).ok();
2545    }
2546
2547    /// The single MOS0015 diagnostic in `diagnostics`, or a panic naming
2548    /// what was found instead.
2549    fn find_unknown_arg_diagnostic(diagnostics: &[Diagnostic]) -> &Diagnostic {
2550        diagnostics
2551            .iter()
2552            .find(|d| d.def().code() == codes::MOS0015.code())
2553            .expect("expected an MOS0015 unknown-argument diagnostic")
2554    }
2555
2556    #[test]
2557    fn image_unknown_arg_close_typo_suggests_key() {
2558        // `wdith` is one adjacent transposition from `width`; the MOS0015
2559        // carries a machine-applicable fix replacing only the key token.
2560        let src = "#image(\"x.png\", wdith: 100pt)\n";
2561        let r = lower(src, &PathBuf::from("test.mos"));
2562        let d = find_unknown_arg_diagnostic(&r.diagnostics);
2563        let suggestions = d.suggestions();
2564        assert_eq!(
2565            suggestions.len(),
2566            1,
2567            "expected one nearest-key suggestion, got {suggestions:?}"
2568        );
2569        assert_eq!(suggestions[0].replacement, "width");
2570        assert_eq!(
2571            &src[suggestions[0].span.start()..suggestions[0].span.end()],
2572            "wdith",
2573            "fix must replace only the key token"
2574        );
2575    }
2576
2577    #[test]
2578    fn image_unknown_arg_far_name_has_no_suggestion() {
2579        // An unrelated key gets the plain diagnostic: a wrong guess is
2580        // worse than none.
2581        let r = lower("#image(\"x.png\", zzz: 1)\n", &PathBuf::from("test.mos"));
2582        let d = find_unknown_arg_diagnostic(&r.diagnostics);
2583        assert!(
2584            d.suggestions().is_empty(),
2585            "an unrelated key must not be guessed, got {:?}",
2586            d.suggestions()
2587        );
2588    }
2589
2590    #[test]
2591    fn figure_unknown_arg_close_typo_suggests_key() {
2592        let src = "#figure(image: \"x.png\", captoin: \"hi\")\n";
2593        let r = lower(src, &PathBuf::from("test.mos"));
2594        let d = find_unknown_arg_diagnostic(&r.diagnostics);
2595        let suggestions = d.suggestions();
2596        assert_eq!(
2597            suggestions.len(),
2598            1,
2599            "expected one nearest-key suggestion, got {suggestions:?}"
2600        );
2601        assert_eq!(suggestions[0].replacement, "caption");
2602        assert_eq!(
2603            &src[suggestions[0].span.start()..suggestions[0].span.end()],
2604            "captoin",
2605            "fix must replace only the key token"
2606        );
2607    }
2608
2609    #[test]
2610    fn bibliography_unknown_arg_close_typo_suggests_key() {
2611        let src = "#bibliography(\"refs.bib\", pth: \"x\")\n";
2612        let r = lower(src, &PathBuf::from("test.mos"));
2613        let d = find_unknown_arg_diagnostic(&r.diagnostics);
2614        let suggestions = d.suggestions();
2615        assert_eq!(
2616            suggestions.len(),
2617            1,
2618            "expected one nearest-key suggestion, got {suggestions:?}"
2619        );
2620        assert_eq!(suggestions[0].replacement, "path");
2621        assert_eq!(
2622            &src[suggestions[0].span.start()..suggestions[0].span.end()],
2623            "pth",
2624            "fix must replace only the key token"
2625        );
2626    }
2627}