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