Skip to main content

mos_eval/
resolve.rs

1//! Cross-reference resolver (manifest §6 stage 3, MVP 1).
2//!
3//! Walks a lowered [`Document`] and, in three passes:
4//!
5//! 1. Assigns hierarchical `number` attributes to every [`NodeKind::Section`]
6//!    (`"1"`, `"1.1"`, `"1.2"`, `"2"`), keyed off the existing `level`
7//!    attribute.
8//! 2. Assigns flat document-order `number` attributes to every numbered
9//!    [`NodeKind::Figure`] (`"1"`, `"2"`, `"3"`) and stamps a visible
10//!    `"{supplement} N: …"` label onto each captioned figure. Figures are
11//!    not hierarchical, so the counter never resets. A figure can opt out
12//!    with `numbered: false` (skipped: no number, no caption prefix, does
13//!    not advance the counter) or swap its supplement word with
14//!    `supplement: "…"` (issue #76).
15//! 3. Builds a `label → LabelTarget` index from every block carrying a
16//!    `label` attribute, then rewrites each [`NodeKind::Reference`]'s
17//!    `text` attribute to its target's resolved string.
18//!
19//! The label index is *typed*: each entry records what kind of thing
20//! the label points at (section, figure, or generic block). A section
21//! reference renders as its bare hierarchical number (`"1.2"`); a figure
22//! reference renders kind-aware as `"{supplement} {n}"` (`"Figure 1"` by
23//! default) from the figure's flat document-order number. Generic targets
24//! (paragraphs, raw blocks, images, skipped figures, …) carry no counter
25//! and render as the bare label, matching prior behavior.
26//!
27//! Diagnostics:
28//!
29//! - `MOS0030`: a label is declared more than once. The first occurrence
30//!   wins; later occurrences keep their numbering but are not added to
31//!   the index. Each duplicate also carries a structured rename
32//!   [`Suggestion`]; the next free `{label}-N` (`N >= 2`) that no other
33//!   declaration or earlier suggestion already uses: over the duplicate
34//!   label token span.
35//! - `MOS0033`: a `@label` reference targets a label that doesn't exist.
36//!   The reference's text is left at its lowered placeholder
37//!   (`?label?`) so it remains visible in the rendered output.
38//!
39//! Manifest §6 stage 3 calls for a fixpoint loop because later stages
40//! (page references, TOC) can re-trigger resolution. MVP 1 only needs a
41//! single pass: section numbering doesn't depend on layout, but the
42//! driver shape mirrors the manifest's "internal fixpoint" anyway: the
43//! loop runs until no rewrite changes the document, with a hard cap to
44//! detect pathological cycles.
45//!
46//! Every pass is **idempotent**: `resolve` is public and re-entrant, so
47//! running it twice: inside the fixpoint above, or from a future
48//! page-reference stage: must reproduce the same document rather than
49//! compounding edits. Numbering overwrites attributes with the same
50//! value; caption labelling re-derives from a preserved source instead
51//! of re-reading the already-stamped text (which would nest the label
52//! into `"Figure 1: Figure 1: …"`).
53
54use std::collections::{BTreeMap, BTreeSet};
55
56use mos_core::{
57    AttrValue, Diagnostic, DiagnosticAnnotation, Document, NodeKind, SourceSpan, Suggestion, codes,
58};
59
60use crate::suggest::edit_distance;
61use crate::{LABEL_SPAN_END_ATTR, LABEL_SPAN_START_ATTR};
62
63/// Cap on resolver fixpoint iterations. MVP 1 always converges in one
64/// pass; the cap is a safety net against forward-reference loops once
65/// page numbering lands in MVP 3+.
66const MAX_FIXPOINT_ITERATIONS: u32 = 8;
67
68/// What a label points at, captured at index-build time.
69///
70/// Each variant carries only the data needed to render the reference's
71/// display text: references never re-traverse the document via the
72/// target [`mos_core::NodeId`] once the index is built, so the resolver can stay
73/// kind-aware without exposing a node-typed handle to callers.
74#[derive(Clone, Debug, Eq, PartialEq)]
75enum LabelTargetKind {
76    /// Heading target with its resolved hierarchical number (e.g.
77    /// `"1.2"`).
78    Section { number: String },
79    /// Captioned figure with its resolved flat document-order number
80    /// (e.g. `"3"`) and supplement word (`"Figure"` by default, or a
81    /// custom `#figure(supplement: …)`). References render kind-aware as
82    /// `"{supplement} {number}"` (e.g. `"Figure 3"`, `"Plate 3"`). A
83    /// skipped (`numbered: false`) figure carries an empty number and
84    /// renders as its bare label instead.
85    Figure { number: String, supplement: String },
86    /// Anything else carrying a label (paragraph, raw block, image, …).
87    Generic,
88}
89
90/// An entry in the label → target index.
91///
92/// `span` is the declaration site, retained so duplicate-label
93/// diagnostics can still point a "first declared here" note at the
94/// original occurrence without re-looking-up the node by id.
95#[derive(Clone, Debug)]
96struct LabelTarget {
97    kind: LabelTargetKind,
98    span: SourceSpan,
99}
100
101/// Run the resolver pass over `document` in place. Returns any
102/// diagnostics produced; the document is modified regardless of whether
103/// errors are present so partial output is still renderable.
104pub fn resolve(document: &mut Document, bib_keys: &BTreeSet<String>) -> Vec<Diagnostic> {
105    let mut diagnostics: Vec<Diagnostic> = Vec::new();
106    number_sections(document);
107    number_figures(document);
108    let labels = build_label_index(document, &mut diagnostics);
109    validate_page_references(document, &labels, &mut diagnostics);
110
111    for _ in 0..MAX_FIXPOINT_ITERATIONS {
112        let changed = rewrite_references(document, &labels, bib_keys, &mut diagnostics);
113        if !changed {
114            break;
115        }
116    }
117
118    diagnostics
119}
120
121/// Report an undeclared label in a `@page(label)` reference as `MOS0033`,
122/// mirroring the `@label` cross-reference check. A page reference resolves to a
123/// page *number* later, through the layout fixpoint (issue #72), but an unknown
124/// *label* is a lower-time error exactly like a bad `@ref`, and catching it
125/// here means `mos check` reports it without needing to lay the document out.
126fn validate_page_references(
127    document: &Document,
128    labels: &BTreeMap<String, LabelTarget>,
129    diagnostics: &mut Vec<Diagnostic>,
130) {
131    for node in document
132        .nodes()
133        .filter(|node| node.kind == NodeKind::PageReference)
134    {
135        let Some(AttrValue::Str(label)) = node.attributes.get("label") else {
136            continue;
137        };
138        if labels.contains_key(label) {
139            continue;
140        }
141        let mut diagnostic = Diagnostic::simple(
142            &codes::MOS0033,
143            None,
144            format!("unknown label `{label}` in `@page` reference"),
145        )
146        .with_span(node.span.clone());
147        if let Some(candidate) = nearest_label(label, labels) {
148            diagnostic = diagnostic.with_suggestion(Suggestion::new(
149                node.span.clone(),
150                format!("@page({candidate})"),
151            ));
152        }
153        diagnostics.push(diagnostic);
154    }
155}
156
157/// Walk the document depth-first and assign hierarchical numbers to
158/// every section based on its `level` attribute. Sections without a
159/// readable `level` default to depth 1.
160fn number_sections(document: &mut Document) {
161    let order = section_order(document);
162    let mut counters: Vec<u32> = Vec::new();
163    for (id, level) in order {
164        let depth = usize::from(level.max(1));
165        if depth > counters.len() {
166            counters.resize(depth, 0);
167        } else {
168            counters.truncate(depth);
169        }
170        counters[depth - 1] += 1;
171        let number = counters
172            .iter()
173            .map(u32::to_string)
174            .collect::<Vec<_>>()
175            .join(".");
176        if let Some(node) = document.get_mut(id) {
177            node.attributes
178                .insert("number".to_owned(), AttrValue::Str(number));
179        }
180    }
181}
182
183fn section_order(document: &Document) -> Vec<(mos_core::NodeId, u8)> {
184    // Scan every `Section` in document order via the shared
185    // `nodes_of_kind` collector (the same traversal figure numbering
186    // uses). MVP 1 only emits flat sections under the root, but walking
187    // the whole arena means nested sections would still be numbered in
188    // order if the lowerer ever produced them.
189    nodes_of_kind(document, NodeKind::Section)
190        .into_iter()
191        .map(|id| {
192            let level = match document.get(id).and_then(|n| n.attributes.get("level")) {
193                Some(AttrValue::Int(n)) => u8::try_from((*n).clamp(1, 255)).unwrap_or(1),
194                _ => 1,
195            };
196            (id, level)
197        })
198        .collect()
199}
200
201/// Assign flat, document-order numbers to every figure (`"1"`, `"2"`,
202/// `"3"`, …) and stamp a visible `"Figure N: …"` label onto each
203/// captioned figure. Figures are not hierarchical, so the counter never
204/// resets.
205///
206/// The label is baked into the caption text here: rather than rendered
207/// by the layout engine the way section numbers are, so a numbered
208/// figure shows its number with no backend changes; distinct label
209/// *styling* is left to the future float/caption pass. The supplement
210/// word comes from [`figure_supplement`] (the single localization seam)
211/// and is joined to the number with a non-breaking space (U+00A0). That
212/// space is *semantic generated text*, not layout policy in disguise: it
213/// encodes `Figure` and its counter as one cohesive label token: the
214/// same non-breaking space an author could type by hand, which the
215/// layout engine merely honors. The resolver makes no wrapping decision
216/// of its own; it just emits the token.
217///
218/// The pass is **idempotent**: the pre-label caption is preserved under a
219/// `caption_source` attribute and the visible `text` is always re-derived
220/// from it. Re-running the resolver: as the §6 stage 3 fixpoint and any
221/// future page-reference pass do: therefore re-stamps the same label
222/// instead of nesting `"Figure 1: Figure 1: …"`, and stays correct when a
223/// figure is re-numbered, because the source never carries a stale counter.
224fn number_figures(document: &mut Document) {
225    // Counter advances only for numbered figures, so `#figure(numbered:
226    // false)` figures neither consume a number nor leave a gap: the
227    // numbered figures stay contiguous (1, 2, 3, …). This is the documented
228    // skip rule (issue #76).
229    let mut counter: usize = 0;
230    for figure_id in nodes_of_kind(document, NodeKind::Figure) {
231        // Read the per-figure controls before taking a `get_mut` borrow.
232        let Some((numbered, supplement)) = document
233            .get(figure_id)
234            .map(|node| (figure_is_numbered(node), figure_supplement_attr(node)))
235        else {
236            continue;
237        };
238        // Resolve the caption's *source* text before mutating: `get`
239        // borrows the document immutably, but the writes below need
240        // `get_mut`. Prefer the preserved `caption_source`; fall back to
241        // the live `text` only on the first pass, before any label has
242        // been stamped. Re-deriving the label from this stable source,
243        // never from the already-stamped `text`; that is what keeps `resolve`
244        // idempotent across reruns.
245        let caption = figure_caption_text(document, figure_id).and_then(|text_id| {
246            read_str_attr(document, text_id, "caption_source")
247                .or_else(|| read_str_attr(document, text_id, "text"))
248                .map(|source| (text_id, source))
249        });
250
251        if numbered {
252            counter += 1;
253            let number = counter.to_string();
254            if let Some(node) = document.get_mut(figure_id) {
255                node.attributes
256                    .insert("number".to_owned(), AttrValue::Str(number.clone()));
257            }
258            if let Some((text_id, caption_source)) = caption {
259                let labelled = format!(
260                    "{}: {caption_source}",
261                    figure_label_prefix(&supplement, &number)
262                );
263                if let Some(node) = document.get_mut(text_id) {
264                    // Stash the pre-label caption so later passes re-derive
265                    // the label from the original instead of the stamped text.
266                    node.attributes
267                        .insert("caption_source".to_owned(), AttrValue::Str(caption_source));
268                    node.attributes
269                        .insert("text".to_owned(), AttrValue::Str(labelled));
270                }
271            }
272        } else {
273            // Skipped figure: carry no number, and restore the caption to its
274            // unprefixed source. Restoring (rather than just not stamping)
275            // keeps the pass idempotent if a figure toggles numbered→skipped
276            // across reruns, undoing any previously stamped `Figure N:`.
277            if let Some(node) = document.get_mut(figure_id) {
278                node.attributes.remove("number");
279            }
280            if let Some((text_id, caption_source)) = caption
281                && let Some(node) = document.get_mut(text_id)
282            {
283                node.attributes.insert(
284                    "caption_source".to_owned(),
285                    AttrValue::Str(caption_source.clone()),
286                );
287                node.attributes
288                    .insert("text".to_owned(), AttrValue::Str(caption_source));
289            }
290        }
291    }
292}
293
294/// Collect the ids of every node of `kind` in document order. `nodes()`
295/// iterates the arena by ascending [`mos_core::NodeId`] (allocation
296/// order), and the lowerer allocates nodes in source order, so the
297/// result is stable document order regardless of nesting depth. Shared
298/// by figure numbering and [`section_order`] so both passes agree on
299/// what "document order" means.
300fn nodes_of_kind(document: &Document, kind: NodeKind) -> Vec<mos_core::NodeId> {
301    document
302        .nodes()
303        .filter(|node| node.kind == kind)
304        .map(|node| node.id)
305        .collect()
306}
307
308/// Find the text node of a figure's caption, if it has one. The lowerer
309/// tags the caption paragraph with `role = "caption"` and gives it a
310/// single [`NodeKind::Text`] child carrying the caption string.
311fn figure_caption_text(
312    document: &Document,
313    figure_id: mos_core::NodeId,
314) -> Option<mos_core::NodeId> {
315    let figure = document.get(figure_id)?;
316    for &child_id in &figure.children {
317        let Some(child) = document.get(child_id) else {
318            continue;
319        };
320        let is_caption = child.kind == NodeKind::Paragraph
321            && matches!(child.attributes.get("role"), Some(AttrValue::Str(role)) if role == "caption");
322        if !is_caption {
323            continue;
324        }
325        for &grandchild_id in &child.children {
326            if document
327                .get(grandchild_id)
328                .is_some_and(|gc| gc.kind == NodeKind::Text)
329            {
330                return Some(grandchild_id);
331            }
332        }
333    }
334    None
335}
336
337/// Read a string attribute off a node by id, cloning it out. `None` if
338/// the node is missing or the attribute is absent or non-string.
339fn read_str_attr(document: &Document, id: mos_core::NodeId, key: &str) -> Option<String> {
340    match document.get(id)?.attributes.get(key) {
341        Some(AttrValue::Str(s)) => Some(s.clone()),
342        _ => None,
343    }
344}
345
346/// The human-facing *supplement* word prefixed to a figure's number in
347/// generated reference and caption text; the "Figure" in "Figure 1".
348///
349/// This is the single localization seam for figure labels: LaTeX
350/// localizes it through babel's `\figurename`, Typst through
351/// `figure.supplement` under the document `text(lang: …)`. Mosaic
352/// captures a document `language` in metadata but does not yet thread it
353/// into the resolver, so this returns the English default; when that
354/// plumbing lands, a language-keyed lookup replaces the constant here
355/// without touching any call site. Sibling kinds (tables, equations,
356/// theorems) grow their own supplements alongside their numbering.
357const fn figure_supplement() -> &'static str {
358    "Figure"
359}
360
361/// Whether a figure participates in the auto `Figure N` counter. A figure
362/// opts out with `#figure(numbered: false)` (issue #76), recorded by the
363/// lowerer as a `numbered = false` attribute; absence means numbered.
364fn figure_is_numbered(node: &mos_core::Node) -> bool {
365    !matches!(
366        node.attributes.get("numbered"),
367        Some(AttrValue::Bool(false))
368    )
369}
370
371/// The supplement word for a figure's caption and its references. An
372/// explicit `#figure(supplement: …)` value wins: **including the empty
373/// string** (`supplement: ""` / `supplement: none`), which means "number
374/// only, no word" (the "no visible prefix" form). Only an *absent*
375/// supplement falls back to the localized [`figure_supplement`] default
376/// (`"Figure"`).
377fn figure_supplement_attr(node: &mos_core::Node) -> String {
378    match node.attributes.get("supplement") {
379        Some(AttrValue::Str(s)) => s.clone(),
380        _ => figure_supplement().to_owned(),
381    }
382}
383
384/// Join a figure's supplement word and number into the cohesive label
385/// token used in both captions and references: `"Figure\u{00A0}1"`,
386/// non-breaking so the word never wraps off its number. An empty
387/// supplement renders the number alone (`"1"`), with no word and no
388/// leading space.
389fn figure_label_prefix(supplement: &str, number: &str) -> String {
390    if supplement.is_empty() {
391        number.to_owned()
392    } else {
393        format!("{supplement}\u{00A0}{number}")
394    }
395}
396
397/// Read a node's resolved `number` attribute, or an empty string if it
398/// has none. Both section and figure numbering stash their counter
399/// there before the label index is built; an empty result means the
400/// numbering pass didn't reach the node (a resolver/lowerer bug).
401fn captured_number(node: &mos_core::Node) -> String {
402    match node.attributes.get("number") {
403        Some(AttrValue::Str(s)) => s.clone(),
404        _ => String::new(),
405    }
406}
407
408/// Classify a labelled node into a [`LabelTargetKind`]. Only nodes
409/// that actually declare a label reach this function: references are
410/// filtered out by the caller.
411fn classify_target(node: &mos_core::Node) -> LabelTargetKind {
412    match node.kind {
413        NodeKind::Section => LabelTargetKind::Section {
414            number: captured_number(node),
415        },
416        NodeKind::Figure => LabelTargetKind::Figure {
417            number: captured_number(node),
418            supplement: figure_supplement_attr(node),
419        },
420        _ => LabelTargetKind::Generic,
421    }
422}
423
424/// Collect every label declared anywhere in the document: any non-reference
425/// block carrying a `label` attribute: regardless of document order or
426/// duplication. The duplicate-rename suggestion consults this set so it never
427/// proposes a name that some other declaration already uses.
428fn declared_labels(document: &Document) -> BTreeSet<String> {
429    document
430        .nodes()
431        .filter(|node| !matches!(node.kind, NodeKind::Reference | NodeKind::PageReference))
432        .filter_map(|node| match node.attributes.get("label") {
433            Some(AttrValue::Str(label)) => Some(label.clone()),
434            _ => None,
435        })
436        .collect()
437}
438
439/// Pick a deterministic, collision-aware rename for a duplicated `label`: the
440/// smallest integer suffix `N >= 2` whose `{label}-{N}` is not already in
441/// `declared`. Boring and stable; no similarity ranking, but it steps over
442/// existing labels so the suggested fix never re-creates the clash it
443/// resolves. Among the first `declared.len() + 1` candidates at least one is
444/// free (pigeonhole), so the bounded search always yields a name.
445fn nonconflicting_rename(label: &str, declared: &BTreeSet<String>) -> String {
446    let ceiling = declared.len().saturating_add(2);
447    (2..=ceiling)
448        .map(|n| format!("{label}-{n}"))
449        .find(|candidate| !declared.contains(candidate))
450        .unwrap_or_else(|| format!("{label}-{ceiling}"))
451}
452
453/// Build the `label -> LabelTarget` index from every label-declaring block,
454/// reporting `MOS0030` for redeclarations. The first declaration of a label
455/// wins; later occurrences keep their numbering but are not indexed, and each
456/// carries a related note pointing at the first declaration plus a structured
457/// rename [`Suggestion`]; the next free `{label}-N`: over the duplicate label
458/// token span (see the module-level docs). Reads the document only, so
459/// `resolve` stays idempotent.
460fn build_label_index(
461    document: &Document,
462    diagnostics: &mut Vec<Diagnostic>,
463) -> BTreeMap<String, LabelTarget> {
464    let mut occupied_labels = declared_labels(document);
465    let mut index: BTreeMap<String, LabelTarget> = BTreeMap::new();
466    for node in document.nodes() {
467        // References *consume* labels; only blocks declare them. Treating a
468        // `@ref` or `@page(ref)`'s `label` attribute as a declaration would
469        // shadow the real target (and falsely trip the duplicate-label check).
470        if matches!(node.kind, NodeKind::Reference | NodeKind::PageReference) {
471            continue;
472        }
473        let Some(AttrValue::Str(label)) = node.attributes.get("label") else {
474            continue;
475        };
476        if let Some(existing) = index.get(label) {
477            // Offer a deterministic, collision-aware rename for the duplicate:
478            // the next free `{label}-N` that no declaration, or earlier
479            // suggestion in this pass, already uses. Still a boring stable
480            // rule, not a similarity-ranked guess. The fix targets only the
481            // duplicate label token span so applying it preserves the
482            // surrounding heading/directive syntax.
483            let rename = nonconflicting_rename(label, &occupied_labels);
484            occupied_labels.insert(rename.clone());
485            let suggestion = label_span(node).map(|span| Suggestion::new(span, rename));
486            let mut diagnostic = Diagnostic::simple(
487                &codes::MOS0030,
488                None,
489                format!("label `{label}` is declared more than once"),
490            )
491            .with_span(node.span.clone())
492            .with_annotation(DiagnosticAnnotation::Related {
493                span: existing.span.clone(),
494                message: format!("first declaration of `{label}` is here"),
495            });
496            if let Some(suggestion) = suggestion {
497                diagnostic = diagnostic.with_suggestion(suggestion);
498            }
499            diagnostics.push(diagnostic);
500            continue;
501        }
502        index.insert(
503            label.clone(),
504            LabelTarget {
505                kind: classify_target(node),
506                span: node.span.clone(),
507            },
508        );
509    }
510    index
511}
512
513fn label_span(node: &mos_core::Node) -> Option<SourceSpan> {
514    let start = match node.attributes.get(LABEL_SPAN_START_ATTR) {
515        Some(AttrValue::Int(value)) => usize::try_from(*value).ok()?,
516        _ => return None,
517    };
518    let end = match node.attributes.get(LABEL_SPAN_END_ATTR) {
519        Some(AttrValue::Int(value)) => usize::try_from(*value).ok()?,
520        _ => return None,
521    };
522    if start > end {
523        return None;
524    }
525    Some(SourceSpan::new(node.span.file.clone(), start, end))
526}
527
528/// Compute the display string for a reference to `target`.
529///
530/// Section targets render as their bare hierarchical counter (e.g.
531/// `"1.2"`). Figure targets render kind-aware as `"Figure N"`: the
532/// localized [`figure_supplement`] joined to the figure's flat
533/// document-order counter with a non-breaking space (U+00A0): one
534/// cohesive label token the layout engine honors, not a wrapping
535/// decision made here (see [`number_figures`]). Generic targets
536/// (paragraphs, images, raw blocks) have no counter and render as the
537/// bare label.
538fn render_target(target: &LabelTarget, label: &str) -> String {
539    match &target.kind {
540        LabelTargetKind::Section { number } if !number.is_empty() => number.clone(),
541        LabelTargetKind::Figure { number, supplement } if !number.is_empty() => {
542            figure_label_prefix(supplement, number)
543        }
544        // A numbered target carrying an empty number is a resolver/lowerer
545        // bug; fall back to the label name so the output stays readable.
546        LabelTargetKind::Section { .. }
547        | LabelTargetKind::Figure { .. }
548        | LabelTargetKind::Generic => label.to_owned(),
549    }
550}
551
552/// Whether `label` can be spelled as an `@` reference: i.e. it is drawn
553/// from the reference grammar's alphabet `[A-Za-z0-9_:.-]` (mirrors
554/// `scan_label_chars` in `mos-parse`). `#figure(label: …)` and
555/// `#image(label: …)` accept arbitrary strings, so the label index can hold
556/// names such as `"intro x"` or non-ASCII labels that an `@…` reference can never name;
557/// suggesting one would produce a fix that does not parse.
558fn is_reference_label(label: &str) -> bool {
559    !label.is_empty()
560        && label
561            .bytes()
562            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'.'))
563}
564
565/// The single nearest *resolvable* label to `unknown`, when one is a
566/// reasonable near-miss rather than an unrelated string; the candidate for a
567/// "did you mean `@intro`?" fix on an unknown reference.
568///
569/// "Reasonable" is deliberately conservative:
570///
571/// - references shorter than three bytes get no suggestion (a one-edit guess
572///   on a one- or two-byte name is noise, not help);
573/// - the edit distance must be within `unknown.len() / 3`: rustc's "did you
574///   mean" heuristic. With the length floor that bound is always at least 1,
575///   admitting `intrdo` → `intro` (distance 1, bound 2) while rejecting wholly
576///   unrelated names.
577///
578/// Candidates are the label-index keys that [`is_reference_label`] accepts.
579/// The index is the resolvable, first-occurrence-wins set, so any surviving
580/// candidate both resolves and is spellable as `@candidate`. Ties break on
581/// `(distance, label)`; the `BTreeMap` already yields labels in sorted order,
582/// so the choice is identical on every run and every fixpoint pass.
583fn nearest_label(unknown: &str, labels: &BTreeMap<String, LabelTarget>) -> Option<String> {
584    if unknown.len() < 3 {
585        return None;
586    }
587    let max_distance = unknown.len() / 3;
588    labels
589        .keys()
590        .filter(|label| is_reference_label(label))
591        .map(|label| (edit_distance(unknown, label), label))
592        .filter(|&(distance, _)| distance <= max_distance)
593        .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)))
594        .map(|(_, label)| label.clone())
595}
596
597/// Rewrite each `Reference` node's `text` attribute to point at its
598/// target. Returns true if any node was mutated this iteration:
599/// callers use that signal to drive the §6 stage 3 fixpoint loop.
600fn rewrite_references(
601    document: &mut Document,
602    labels: &BTreeMap<String, LabelTarget>,
603    bib_keys: &BTreeSet<String>,
604    diagnostics: &mut Vec<Diagnostic>,
605) -> bool {
606    let references: Vec<mos_core::NodeId> = document
607        .nodes()
608        .filter(|n| n.kind == NodeKind::Reference)
609        .map(|n| n.id)
610        .collect();
611
612    let mut changed = false;
613    for ref_id in references {
614        let Some(node) = document.get(ref_id) else {
615            continue;
616        };
617        let Some(AttrValue::Str(label)) = node.attributes.get("label").cloned() else {
618            continue;
619        };
620        let resolved_text = if let Some(target) = labels.get(&label) {
621            render_target(target, &label)
622        } else {
623            let already_diagnosed = diagnostics
624                .iter()
625                .any(|d| d.def().code() == codes::MOS0033.code() && d.span() == Some(&node.span));
626            if !already_diagnosed {
627                let mut diagnostic = Diagnostic::simple(
628                    &codes::MOS0033,
629                    None,
630                    format!("unknown label `{label}` in `@` reference"),
631                )
632                .with_span(node.span.clone());
633                // An `@key` that misses every label but exactly matches a
634                // bibliography key is a citation written with the wrong
635                // syntax (`@key` instead of `[@key]`). That exact match is a
636                // stronger signal than any label near-miss, so it wins: offer
637                // the citation form and say why. `node.span` covers the whole
638                // `@label` token (sigil included), so the replacement supplies
639                // the full `[@key]`.
640                if bib_keys.contains(&label) {
641                    diagnostic = diagnostic
642                        .with_annotation(DiagnosticAnnotation::Hint(format!(
643                            "`{label}` is a bibliography key; cite it as `[@{label}]`"
644                        )))
645                        .with_suggestion(Suggestion::new(node.span.clone(), format!("[@{label}]")));
646                } else if let Some(candidate) = nearest_label(&label, labels) {
647                    // Offer the nearest existing label as a machine-applicable
648                    // fix (`@intrdo` -> `@intro`) when a reasonable near-miss
649                    // exists. The replacement carries its own `@`.
650                    diagnostic = diagnostic.with_suggestion(Suggestion::new(
651                        node.span.clone(),
652                        format!("@{candidate}"),
653                    ));
654                }
655                diagnostics.push(diagnostic);
656            }
657            continue;
658        };
659
660        if let Some(node) = document.get_mut(ref_id) {
661            let new = AttrValue::Str(resolved_text);
662            if node.attributes.get("text") != Some(&new) {
663                node.attributes.insert("text".to_owned(), new);
664                changed = true;
665            }
666        }
667    }
668    changed
669}
670
671#[cfg(test)]
672mod tests {
673    use std::path::PathBuf;
674
675    use mos_core::Severity;
676
677    use super::*;
678
679    fn lower(src: &str) -> (Document, Vec<Diagnostic>) {
680        let r = crate::lower(src, &PathBuf::from("test.mos"));
681        (r.document, r.diagnostics)
682    }
683
684    fn apply_suggestion(src: &str, suggestion: &Suggestion) -> String {
685        let mut out = String::new();
686        out.push_str(&src[..suggestion.span.start()]);
687        out.push_str(&suggestion.replacement);
688        out.push_str(&src[suggestion.span.end()..]);
689        out
690    }
691
692    fn section_numbers(doc: &Document) -> Vec<(String, String)> {
693        doc.nodes()
694            .filter(|n| n.kind == NodeKind::Section)
695            .map(|n| {
696                let title = n
697                    .children
698                    .iter()
699                    .filter_map(|c| doc.get(*c))
700                    .find_map(|c| match c.attributes.get("text") {
701                        Some(AttrValue::Str(s)) => Some(s.clone()),
702                        _ => None,
703                    })
704                    .unwrap_or_default();
705                let number = match n.attributes.get("number") {
706                    Some(AttrValue::Str(s)) => s.clone(),
707                    _ => String::new(),
708                };
709                (title, number)
710            })
711            .collect()
712    }
713
714    #[test]
715    fn assigns_hierarchical_section_numbers() {
716        let (doc, diags) = lower("= Intro\n\n== Background\n\n== Aims\n\n= Methods\n\n== Sample\n");
717        assert!(diags.is_empty(), "{diags:?}");
718        let nums = section_numbers(&doc);
719        let pairs: Vec<(&str, &str)> = nums.iter().map(|(t, n)| (t.as_str(), n.as_str())).collect();
720        assert_eq!(
721            pairs,
722            vec![
723                ("Intro", "1"),
724                ("Background", "1.1"),
725                ("Aims", "1.2"),
726                ("Methods", "2"),
727                ("Sample", "2.1"),
728            ]
729        );
730    }
731
732    #[test]
733    fn duplicate_label_emits_mos0030_and_keeps_first() {
734        let src = "= A <dup>\n\n= B <dup>\n\nsee @dup\n";
735        let (doc, diags) = lower(src);
736        let mos0030: Vec<&Diagnostic> = diags
737            .iter()
738            .filter(|d| d.def().code() == codes::MOS0030.code())
739            .collect();
740        assert_eq!(
741            mos0030.len(),
742            1,
743            "expected exactly one MOS0030, got {diags:?}"
744        );
745        let d = mos0030[0];
746        assert_eq!(d.def().code(), codes::MOS0030.code());
747        assert_eq!(d.severity(), Severity::Error);
748        assert!(
749            d.message().contains("`dup`"),
750            "MOS0030 message should name the duplicated label, got {:?}",
751            d.message()
752        );
753        // The duplicate diagnostic must point at the *second* occurrence
754        // and carry a Related annotation back to the first declaration.
755        // Editor UIs rely on both spans to render the redeclaration jump.
756        assert_eq!(
757            d.span().map(|span| &src[span.start()..span.end()]),
758            Some("= B <dup>"),
759            "MOS0030 span should cover the second heading exactly"
760        );
761        assert_eq!(
762            d.annotations().len(),
763            1,
764            "MOS0030 should reference the first decl"
765        );
766        let related = d.annotations().iter().find_map(|a| match a {
767            DiagnosticAnnotation::Related { span, message } => Some((span, message)),
768            _ => None,
769        });
770        assert!(related.is_some(), "MOS0030 carries a Related annotation");
771        if let Some((note_span, note_message)) = related {
772            assert_eq!(
773                &src[note_span.start()..note_span.end()],
774                "= A <dup>",
775                "MOS0030 note should point at the original declaration exactly"
776            );
777            assert!(
778                note_message.contains("`dup`"),
779                "first-decl note should name the label, got {note_message:?}"
780            );
781        }
782        // The duplicate carries exactly one structured rename suggestion:
783        // replace only the duplicate label token with the smallest free
784        // `dup-2` candidate (nothing else here claims it). Editors apply this
785        // as a fix-it, so the payload: span + replacement: must preserve the
786        // surrounding heading syntax.
787        let suggestions = d.suggestions();
788        assert_eq!(
789            suggestions.len(),
790            1,
791            "MOS0030 should carry exactly one rename suggestion, got {suggestions:?}"
792        );
793        if let Some(suggestion) = suggestions.first() {
794            assert_eq!(
795                &src[suggestion.span.start()..suggestion.span.end()],
796                "dup",
797                "suggestion span should cover only the duplicate label token"
798            );
799            assert_eq!(
800                suggestion.replacement, "dup-2",
801                "suggestion should rename the duplicate label deterministically"
802            );
803            assert_eq!(
804                apply_suggestion(src, suggestion),
805                "= A <dup>\n\n= B <dup-2>\n\nsee @dup\n",
806                "applying the fix must preserve the heading and label delimiters"
807            );
808        }
809        // Reference still resolves to the first declaration's number.
810        let reference_text = doc
811            .nodes()
812            .find(|n| n.kind == NodeKind::Reference)
813            .and_then(|n| n.attributes.get("text"));
814        assert_eq!(reference_text, Some(&AttrValue::Str("1".to_owned())));
815    }
816
817    #[test]
818    fn triple_duplicate_label_emits_one_mos0030_per_redeclaration() {
819        // Three sections share `dup`. The first wins; the second and
820        // third each get their own MOS0030 pointing back at the first.
821        // The reference still resolves to section number `1`.
822        let src = "= A <dup>\n\n= B <dup>\n\n= C <dup>\n\nsee @dup\n";
823        let (doc, diags) = lower(src);
824        let mos0030: Vec<&Diagnostic> = diags
825            .iter()
826            .filter(|d| d.def().code() == codes::MOS0030.code())
827            .collect();
828        assert_eq!(
829            mos0030.len(),
830            2,
831            "expected two MOS0030 (one per redeclaration), got {diags:?}"
832        );
833        let spans: Vec<&str> = mos0030
834            .iter()
835            .filter_map(|d| d.span().map(|s| &src[s.start()..s.end()]))
836            .collect();
837        assert_eq!(
838            spans.len(),
839            mos0030.len(),
840            "every MOS0030 must carry a primary span"
841        );
842        assert!(
843            spans.contains(&"= B <dup>"),
844            "missing span for second decl, got {spans:?}"
845        );
846        assert!(
847            spans.contains(&"= C <dup>"),
848            "missing span for third decl, got {spans:?}"
849        );
850        // Every duplicate diagnostic must reference the same first decl.
851        for d in &mos0030 {
852            let related = d.annotations().iter().find_map(|a| match a {
853                DiagnosticAnnotation::Related { span, message } => Some((span, message)),
854                _ => None,
855            });
856            assert!(related.is_some(), "MOS0030 carries a Related annotation");
857            if let Some((ns, _)) = related {
858                assert_eq!(
859                    &src[ns.start()..ns.end()],
860                    "= A <dup>",
861                    "every redeclaration must link back to the first decl"
862                );
863            }
864            // Each redeclaration carries its own deterministic rename
865            // suggestion over its own label-token span. Generated suggestions
866            // are reserved during this resolver pass, so bulk-applying both
867            // fixes does not create a fresh duplicate.
868            let suggestions = d.suggestions();
869            assert_eq!(
870                suggestions.len(),
871                1,
872                "each MOS0030 carries exactly one rename suggestion, got {suggestions:?}"
873            );
874            if let Some(suggestion) = suggestions.first() {
875                assert_eq!(&src[suggestion.span.start()..suggestion.span.end()], "dup");
876            }
877        }
878        let replacements: Vec<&str> = mos0030
879            .iter()
880            .filter_map(|d| d.suggestions().first())
881            .map(|suggestion| suggestion.replacement.as_str())
882            .collect();
883        assert_eq!(replacements, vec!["dup-2", "dup-3"]);
884        let reference_text = doc
885            .nodes()
886            .find(|n| n.kind == NodeKind::Reference)
887            .and_then(|n| n.attributes.get("text"));
888        assert_eq!(reference_text, Some(&AttrValue::Str("1".to_owned())));
889    }
890
891    #[test]
892    fn duplicate_suggestion_skips_existing_label() {
893        // `dup-2` already names another block, so the collision-aware rename
894        // for the duplicate `dup` must step over it to `dup-3` rather than
895        // propose a name that would just re-collide. Only `dup` is
896        // duplicated; `dup-2` is a distinct, valid label (hyphens are legal
897        // label chars).
898        let src = "= A <dup>\n\n= B <dup-2>\n\n= C <dup>\n";
899        let (_doc, diags) = lower(src);
900        let mos0030: Vec<&Diagnostic> = diags
901            .iter()
902            .filter(|d| d.def().code() == codes::MOS0030.code())
903            .collect();
904        assert_eq!(mos0030.len(), 1, "only `dup` is duplicated, got {diags:?}");
905        let d = mos0030[0];
906        let suggestions = d.suggestions();
907        assert_eq!(
908            suggestions.len(),
909            1,
910            "the duplicate carries one rename suggestion, got {suggestions:?}"
911        );
912        if let Some(suggestion) = suggestions.first() {
913            assert_eq!(
914                suggestion.replacement, "dup-3",
915                "rename must skip the existing `dup-2` and land on the next free suffix"
916            );
917            assert_eq!(
918                &src[suggestion.span.start()..suggestion.span.end()],
919                "dup",
920                "suggestion targets the duplicate label token"
921            );
922            assert_eq!(
923                apply_suggestion(src, suggestion),
924                "= A <dup>\n\n= B <dup-2>\n\n= C <dup-3>\n",
925                "applying the fix must preserve the duplicate declaration syntax"
926            );
927        }
928    }
929
930    #[test]
931    fn unknown_label_emits_mos0033() {
932        let (doc, diags) = lower("see @no:such\n");
933        let mos0033: Vec<&Diagnostic> = diags
934            .iter()
935            .filter(|d| d.def().code() == codes::MOS0033.code())
936            .collect();
937        assert_eq!(
938            mos0033.len(),
939            1,
940            "expected exactly one MOS0033 even with the fixpoint loop, got {diags:?}"
941        );
942        let d = mos0033[0];
943        assert_eq!(d.def().code(), codes::MOS0033.code());
944        assert_eq!(d.severity(), Severity::Error);
945        assert!(
946            d.message().contains("`no:such`"),
947            "MOS0033 message should name the missing label, got {:?}",
948            d.message()
949        );
950        assert!(
951            d.span().is_some(),
952            "MOS0033 must carry a span so editors can jump to the bad reference"
953        );
954        let reference_text = doc
955            .nodes()
956            .find(|n| n.kind == NodeKind::Reference)
957            .and_then(|n| n.attributes.get("text"));
958        // Placeholder text is preserved so the diagnostic location is
959        // visible in the rendered output.
960        assert_eq!(
961            reference_text,
962            Some(&AttrValue::Str("?no:such?".to_owned()))
963        );
964    }
965
966    #[test]
967    fn multiple_unknown_references_each_emit_one_mos0033() {
968        // Three distinct unknown labels in a single paragraph produce one
969        // diagnostic apiece in a single resolver pass.
970        let src = "see @alpha and @beta and @gamma\n";
971        let (_doc, diags) = lower(src);
972        let mos0033: Vec<&Diagnostic> = diags
973            .iter()
974            .filter(|d| d.def().code() == codes::MOS0033.code())
975            .collect();
976        assert_eq!(
977            mos0033.len(),
978            3,
979            "expected one MOS0033 per unknown label, got {diags:?}"
980        );
981        let labels: BTreeSet<&str> = mos0033
982            .iter()
983            .filter_map(|d| {
984                // Each MOS0033's message is `unknown label `<name>` in `@` reference`.
985                let msg = &d.message();
986                let start = msg.find('`')? + 1;
987                let end = start + msg[start..].find('`')?;
988                Some(&msg[start..end])
989            })
990            .collect();
991        assert_eq!(
992            labels,
993            ["alpha", "beta", "gamma"].into_iter().collect(),
994            "each unknown label should appear exactly once"
995        );
996    }
997
998    #[test]
999    fn unknown_reference_suggestion_is_not_duplicated_after_fixpoint_rerun() {
1000        // The resolved `@intro` changes reference text on the first pass, so
1001        // the fixpoint runs again. The unknown `@intrdo` must still get one
1002        // MOS0033 with one structured suggestion, not one per iteration.
1003        let src = "= Intro <intro>\n\nsee @intro and @intrdo\n";
1004        let (doc, diags) = lower(src);
1005        let mos0033: Vec<&Diagnostic> = diags
1006            .iter()
1007            .filter(|d| d.def().code() == codes::MOS0033.code())
1008            .collect();
1009        assert_eq!(
1010            mos0033.len(),
1011            1,
1012            "expected one MOS0033 after fixpoint rerun, got {diags:?}"
1013        );
1014        let d = mos0033[0];
1015        let suggestions = d.suggestions();
1016        assert_eq!(
1017            suggestions.len(),
1018            1,
1019            "expected one suggestion after fixpoint rerun, got {suggestions:?}"
1020        );
1021        if let Some(suggestion) = suggestions.first() {
1022            assert_eq!(suggestion.replacement, "@intro");
1023            assert_eq!(
1024                apply_suggestion(src, suggestion),
1025                "= Intro <intro>\n\nsee @intro and @intro\n",
1026                "fix should replace only the unknown reference token"
1027            );
1028        }
1029        let reference_texts: Vec<&str> = doc
1030            .nodes()
1031            .filter(|n| n.kind == NodeKind::Reference)
1032            .filter_map(|n| match n.attributes.get("text") {
1033                Some(AttrValue::Str(s)) => Some(s.as_str()),
1034                _ => None,
1035            })
1036            .collect();
1037        assert_eq!(
1038            reference_texts,
1039            vec!["1", "?intrdo?"],
1040            "resolved refs rewrite while unknown refs keep visible placeholders"
1041        );
1042    }
1043
1044    #[test]
1045    fn reference_resolves_to_section_number() {
1046        let (doc, diags) =
1047            lower("= Intro <intro>\n\n= Methods <methods>\n\nsee @methods and @intro\n");
1048        assert!(diags.is_empty(), "{diags:?}");
1049        let refs: Vec<String> = doc
1050            .nodes()
1051            .filter(|n| n.kind == NodeKind::Reference)
1052            .filter_map(|n| match n.attributes.get("text") {
1053                Some(AttrValue::Str(s)) => Some(s.clone()),
1054                _ => None,
1055            })
1056            .collect();
1057        assert_eq!(refs, vec!["2".to_owned(), "1".to_owned()]);
1058    }
1059
1060    #[test]
1061    fn paragraph_label_indexes_paragraph() {
1062        // A paragraph-attached label has no section number, so the
1063        // resolver falls back to using the bare label as the rewritten
1064        // text. No MOS0033 is emitted because the target exists.
1065        let (doc, diags) = lower("<note> a side note here\n\nsee @note\n");
1066        assert!(diags.is_empty(), "{diags:?}");
1067        let reference_text = doc
1068            .nodes()
1069            .find(|n| n.kind == NodeKind::Reference)
1070            .and_then(|n| n.attributes.get("text"));
1071        assert_eq!(reference_text, Some(&AttrValue::Str("note".to_owned())));
1072    }
1073
1074    /// Build a synthetic node with `kind`, `label`, and (optionally) a
1075    /// section `number`. Used by classifier tests to exercise typed
1076    /// targets without dragging in image/file I/O.
1077    fn make_node(
1078        doc: &mut Document,
1079        kind: NodeKind,
1080        label: Option<&str>,
1081        number: Option<&str>,
1082    ) -> mos_core::NodeId {
1083        let mut attrs = mos_core::AttrMap::new();
1084        if let Some(l) = label {
1085            attrs.insert("label".to_owned(), AttrValue::Str(l.to_owned()));
1086        }
1087        if let Some(n) = number {
1088            attrs.insert("number".to_owned(), AttrValue::Str(n.to_owned()));
1089        }
1090        doc.alloc_child(
1091            doc.root,
1092            mos_core::NodeSpec::new(kind, SourceSpan::placeholder(doc.file.clone()))
1093                .with_attributes(attrs),
1094        )
1095    }
1096
1097    /// Build a synthetic `Text` node under `parent` carrying `text`,
1098    /// returning its id. The lowerer's caption text nodes have exactly
1099    /// this shape.
1100    fn make_text(doc: &mut Document, parent: mos_core::NodeId, text: &str) -> mos_core::NodeId {
1101        let mut attrs = mos_core::AttrMap::new();
1102        attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
1103        doc.alloc_child(
1104            parent,
1105            mos_core::NodeSpec::new(NodeKind::Text, SourceSpan::placeholder(doc.file.clone()))
1106                .with_attributes(attrs),
1107        )
1108    }
1109
1110    /// Build a `Figure` (optionally labelled) carrying a `role = "caption"`
1111    /// paragraph whose single `Text` child holds `caption`. Returns the
1112    /// figure id and the caption text-node id so tests can assert on the
1113    /// stamped label. Mirrors the shape the lowerer produces for a
1114    /// captioned `#figure`.
1115    fn make_captioned_figure(
1116        doc: &mut Document,
1117        label: Option<&str>,
1118        caption: &str,
1119    ) -> (mos_core::NodeId, mos_core::NodeId) {
1120        let figure = make_node(doc, NodeKind::Figure, label, None);
1121        let mut caption_attrs = mos_core::AttrMap::new();
1122        caption_attrs.insert("role".to_owned(), AttrValue::Str("caption".to_owned()));
1123        let caption_para = doc.alloc_child(
1124            figure,
1125            mos_core::NodeSpec::new(
1126                NodeKind::Paragraph,
1127                SourceSpan::placeholder(doc.file.clone()),
1128            )
1129            .with_attributes(caption_attrs),
1130        );
1131        let caption_text = make_text(doc, caption_para, caption);
1132        (figure, caption_text)
1133    }
1134
1135    /// Read a node's resolved `number` attribute as an owned string, or
1136    /// the empty string if the node is missing or unnumbered. Test-only
1137    /// convenience wrapping [`captured_number`] for the numbering
1138    /// assertions below.
1139    fn node_number(doc: &Document, id: mos_core::NodeId) -> String {
1140        doc.get(id).map(captured_number).unwrap_or_default()
1141    }
1142
1143    #[test]
1144    fn classify_target_distinguishes_kinds() {
1145        let mut doc = Document::new(PathBuf::from("test.mos"));
1146        let section_id = make_node(&mut doc, NodeKind::Section, Some("sec"), Some("1.2"));
1147        let figure_id = make_node(&mut doc, NodeKind::Figure, Some("fig"), Some("3"));
1148        let paragraph_id = make_node(&mut doc, NodeKind::Paragraph, Some("p"), None);
1149
1150        assert_eq!(
1151            doc.get(section_id).map(classify_target),
1152            Some(LabelTargetKind::Section {
1153                number: "1.2".to_owned()
1154            })
1155        );
1156
1157        assert_eq!(
1158            doc.get(figure_id).map(classify_target),
1159            Some(LabelTargetKind::Figure {
1160                number: "3".to_owned(),
1161                supplement: "Figure".to_owned(),
1162            })
1163        );
1164
1165        assert_eq!(
1166            doc.get(paragraph_id).map(classify_target),
1167            Some(LabelTargetKind::Generic)
1168        );
1169    }
1170
1171    #[test]
1172    fn figure_reference_renders_kind_aware_text() {
1173        // Constructs a Figure node with a label and a Reference to it,
1174        // then runs the full resolver. Verifies:
1175        //   - the figure receives document-order number "1",
1176        //   - the figure label is found (no MOS0033),
1177        //   - the label index records the target as a numbered `Figure`,
1178        //   - the reference's rewritten text is kind-aware `"Figure 1"`,
1179        //     not the bare label name.
1180        let mut doc = Document::new(PathBuf::from("test.mos"));
1181        let figure_id = make_node(&mut doc, NodeKind::Figure, Some("fig:one"), None);
1182        let ref_id = doc.alloc_child(
1183            doc.root,
1184            mos_core::NodeSpec::new(
1185                NodeKind::Reference,
1186                SourceSpan::placeholder(doc.file.clone()),
1187            )
1188            .with_attributes({
1189                let mut a = mos_core::AttrMap::new();
1190                a.insert("label".to_owned(), AttrValue::Str("fig:one".to_owned()));
1191                a.insert("text".to_owned(), AttrValue::Str("?fig:one?".to_owned()));
1192                a
1193            }),
1194        );
1195
1196        let diags = resolve(&mut doc, &BTreeSet::new());
1197        assert!(diags.is_empty(), "{diags:?}");
1198
1199        // The figure carries its resolved document-order number.
1200        assert_eq!(
1201            doc.get(figure_id).and_then(|f| f.attributes.get("number")),
1202            Some(&AttrValue::Str("1".to_owned()))
1203        );
1204
1205        let mut sink: Vec<Diagnostic> = Vec::new();
1206        let index = build_label_index(&doc, &mut sink);
1207        assert!(sink.is_empty(), "{sink:?}");
1208        assert_eq!(
1209            index.get("fig:one").map(|target| &target.kind),
1210            Some(&LabelTargetKind::Figure {
1211                number: "1".to_owned(),
1212                supplement: "Figure".to_owned(),
1213            })
1214        );
1215
1216        assert_eq!(
1217            doc.get(ref_id).and_then(|r| r.attributes.get("text")),
1218            Some(&AttrValue::Str("Figure\u{00A0}1".to_owned())),
1219            "a figure reference resolves to kind-aware `Figure N` text, joined by a non-breaking space"
1220        );
1221    }
1222
1223    #[test]
1224    fn captioned_figure_gets_supplement_label_stamped() {
1225        // A figure with a `role = "caption"` paragraph gets its caption
1226        // text prefixed with the non-breaking `Figure N: ` label so the
1227        // number is visible; the figure itself is still numbered "1".
1228        let mut doc = Document::new(PathBuf::from("test.mos"));
1229        let (figure, caption_text) = make_captioned_figure(&mut doc, Some("fig:a"), "A plot.");
1230
1231        let diags = resolve(&mut doc, &BTreeSet::new());
1232        assert!(diags.is_empty(), "{diags:?}");
1233
1234        assert_eq!(node_number(&doc, figure), "1");
1235        assert_eq!(
1236            read_str_attr(&doc, caption_text, "text"),
1237            Some("Figure\u{00A0}1: A plot.".to_owned()),
1238            "the caption is prefixed with the non-breaking `Figure N: ` label"
1239        );
1240    }
1241
1242    #[test]
1243    fn skipped_figure_omits_label_and_does_not_advance_counter() {
1244        // `#figure(numbered: false)` opts out of numbering (issue #76): no
1245        // `number` attribute, no `Figure N:` caption prefix, and the
1246        // documented counter rule; the skip does not advance the counter,
1247        // so a later numbered figure is still "Figure 1", not "Figure 2".
1248        let mut doc = Document::new(PathBuf::from("test.mos"));
1249        let (skipped, skipped_caption) =
1250            make_captioned_figure(&mut doc, Some("fig:skip"), "Decorative.");
1251        if let Some(node) = doc.get_mut(skipped) {
1252            node.attributes
1253                .insert("numbered".to_owned(), AttrValue::Bool(false));
1254        }
1255        let (numbered, numbered_caption) =
1256            make_captioned_figure(&mut doc, Some("fig:num"), "A plot.");
1257
1258        let diags = resolve(&mut doc, &BTreeSet::new());
1259        assert!(diags.is_empty(), "{diags:?}");
1260
1261        assert_eq!(
1262            node_number(&doc, skipped),
1263            "",
1264            "a skipped figure carries no number"
1265        );
1266        assert_eq!(
1267            read_str_attr(&doc, skipped_caption, "text"),
1268            Some("Decorative.".to_owned()),
1269            "a skipped figure's caption keeps no `Figure N:` prefix"
1270        );
1271        assert_eq!(
1272            node_number(&doc, numbered),
1273            "1",
1274            "the skipped figure must not consume or gap the counter"
1275        );
1276        assert_eq!(
1277            read_str_attr(&doc, numbered_caption, "text"),
1278            Some("Figure\u{00A0}1: A plot.".to_owned())
1279        );
1280    }
1281
1282    #[test]
1283    fn custom_supplement_renders_in_caption_and_reference() {
1284        // `#figure(supplement: "Plate")` swaps the supplement word in both
1285        // the stamped caption and any reference to the figure (issue #76).
1286        let mut doc = Document::new(PathBuf::from("test.mos"));
1287        let (figure, caption_text) = make_captioned_figure(&mut doc, Some("fig:plate"), "A map.");
1288        if let Some(node) = doc.get_mut(figure) {
1289            node.attributes
1290                .insert("supplement".to_owned(), AttrValue::Str("Plate".to_owned()));
1291        }
1292        let ref_id = doc.alloc_child(
1293            doc.root,
1294            mos_core::NodeSpec::new(
1295                NodeKind::Reference,
1296                SourceSpan::placeholder(doc.file.clone()),
1297            )
1298            .with_attributes({
1299                let mut a = mos_core::AttrMap::new();
1300                a.insert("label".to_owned(), AttrValue::Str("fig:plate".to_owned()));
1301                a.insert("text".to_owned(), AttrValue::Str("?fig:plate?".to_owned()));
1302                a
1303            }),
1304        );
1305
1306        let diags = resolve(&mut doc, &BTreeSet::new());
1307        assert!(diags.is_empty(), "{diags:?}");
1308
1309        assert_eq!(
1310            read_str_attr(&doc, caption_text, "text"),
1311            Some("Plate\u{00A0}1: A map.".to_owned()),
1312            "the caption uses the custom supplement word"
1313        );
1314        assert_eq!(
1315            doc.get(ref_id).and_then(|r| r.attributes.get("text")),
1316            Some(&AttrValue::Str("Plate\u{00A0}1".to_owned())),
1317            "a reference renders the custom supplement, not `Figure`"
1318        );
1319    }
1320
1321    #[test]
1322    fn empty_supplement_renders_number_only() {
1323        // `#figure(supplement: "")` / `supplement: none` keeps the figure
1324        // numbered but drops the supplement word: the caption and any
1325        // reference show the number alone; the "no visible prefix" form
1326        // (issue #76). Distinct from `numbered: false`, which drops the
1327        // number entirely.
1328        let mut doc = Document::new(PathBuf::from("test.mos"));
1329        let (figure, caption_text) = make_captioned_figure(&mut doc, Some("fig:plain"), "A chart.");
1330        if let Some(node) = doc.get_mut(figure) {
1331            node.attributes
1332                .insert("supplement".to_owned(), AttrValue::Str(String::new()));
1333        }
1334        let ref_id = doc.alloc_child(
1335            doc.root,
1336            mos_core::NodeSpec::new(
1337                NodeKind::Reference,
1338                SourceSpan::placeholder(doc.file.clone()),
1339            )
1340            .with_attributes({
1341                let mut a = mos_core::AttrMap::new();
1342                a.insert("label".to_owned(), AttrValue::Str("fig:plain".to_owned()));
1343                a.insert("text".to_owned(), AttrValue::Str("?fig:plain?".to_owned()));
1344                a
1345            }),
1346        );
1347
1348        let diags = resolve(&mut doc, &BTreeSet::new());
1349        assert!(diags.is_empty(), "{diags:?}");
1350
1351        assert_eq!(
1352            read_str_attr(&doc, caption_text, "text"),
1353            Some("1: A chart.".to_owned()),
1354            "an empty supplement renders the number with no word and no leading space"
1355        );
1356        assert_eq!(
1357            doc.get(ref_id).and_then(|r| r.attributes.get("text")),
1358            Some(&AttrValue::Str("1".to_owned())),
1359            "a reference to a number-only figure renders just the number"
1360        );
1361    }
1362
1363    #[test]
1364    fn reference_to_skipped_figure_renders_bare_label() {
1365        // A reference to a `numbered: false` figure has no number to show,
1366        // so it falls back to the bare label name: like an image reference.
1367        let mut doc = Document::new(PathBuf::from("test.mos"));
1368        let figure = make_node(&mut doc, NodeKind::Figure, Some("fig:skip"), None);
1369        if let Some(node) = doc.get_mut(figure) {
1370            node.attributes
1371                .insert("numbered".to_owned(), AttrValue::Bool(false));
1372        }
1373        let ref_id = doc.alloc_child(
1374            doc.root,
1375            mos_core::NodeSpec::new(
1376                NodeKind::Reference,
1377                SourceSpan::placeholder(doc.file.clone()),
1378            )
1379            .with_attributes({
1380                let mut a = mos_core::AttrMap::new();
1381                a.insert("label".to_owned(), AttrValue::Str("fig:skip".to_owned()));
1382                a.insert("text".to_owned(), AttrValue::Str("?fig:skip?".to_owned()));
1383                a
1384            }),
1385        );
1386
1387        let diags = resolve(&mut doc, &BTreeSet::new());
1388        assert!(diags.is_empty(), "{diags:?}");
1389
1390        assert_eq!(
1391            doc.get(ref_id).and_then(|r| r.attributes.get("text")),
1392            Some(&AttrValue::Str("fig:skip".to_owned())),
1393            "a reference to a skipped figure renders the bare label"
1394        );
1395    }
1396
1397    #[test]
1398    fn resolve_is_idempotent_for_captioned_figures() {
1399        // `resolve` is public and re-entrant: the §6 stage 3 fixpoint and
1400        // future page-reference passes rerun it. Stamping the caption
1401        // label must therefore be idempotent; the second pass has to
1402        // reproduce `"Figure 1: A plot."` byte-for-byte instead of
1403        // re-reading the stamped text and nesting the label into
1404        // `"Figure 1: Figure 1: A plot."`.
1405        let mut doc = Document::new(PathBuf::from("test.mos"));
1406        let (_figure, caption_text) = make_captioned_figure(&mut doc, Some("fig:a"), "A plot.");
1407
1408        let first = resolve(&mut doc, &BTreeSet::new());
1409        assert!(first.is_empty(), "{first:?}");
1410        let after_first = read_str_attr(&doc, caption_text, "text");
1411        assert_eq!(after_first, Some("Figure\u{00A0}1: A plot.".to_owned()));
1412
1413        let second = resolve(&mut doc, &BTreeSet::new());
1414        assert!(second.is_empty(), "{second:?}");
1415        assert_eq!(
1416            read_str_attr(&doc, caption_text, "text"),
1417            after_first,
1418            "a second resolve pass must not re-stamp the figure label"
1419        );
1420    }
1421
1422    #[test]
1423    fn figures_get_sequential_document_order_numbers() {
1424        // Three figures, one without a label, get flat document-order
1425        // numbers. Numbering is unconditional: the unlabelled middle
1426        // figure still advances the counter.
1427        let mut doc = Document::new(PathBuf::from("test.mos"));
1428        let first = make_node(&mut doc, NodeKind::Figure, Some("fig:a"), None);
1429        let middle = make_node(&mut doc, NodeKind::Figure, None, None);
1430        let last = make_node(&mut doc, NodeKind::Figure, Some("fig:c"), None);
1431
1432        let diags = resolve(&mut doc, &BTreeSet::new());
1433        assert!(diags.is_empty(), "{diags:?}");
1434
1435        assert_eq!(node_number(&doc, first), "1");
1436        assert_eq!(
1437            node_number(&doc, middle),
1438            "2",
1439            "unlabelled figures are still numbered"
1440        );
1441        assert_eq!(node_number(&doc, last), "3");
1442    }
1443
1444    #[test]
1445    fn figures_and_sections_use_independent_counters() {
1446        // Sections and figures count independently: a figure sandwiched
1447        // between two sections is still figure "1", and the sections are
1448        // "1"/"2" regardless of the figures interleaved with them.
1449        let mut doc = Document::new(PathBuf::from("test.mos"));
1450        let sec_one = make_node(&mut doc, NodeKind::Section, Some("sec:a"), None);
1451        let fig_one = make_node(&mut doc, NodeKind::Figure, Some("fig:a"), None);
1452        let sec_two = make_node(&mut doc, NodeKind::Section, Some("sec:b"), None);
1453        let fig_two = make_node(&mut doc, NodeKind::Figure, Some("fig:b"), None);
1454
1455        let diags = resolve(&mut doc, &BTreeSet::new());
1456        assert!(diags.is_empty(), "{diags:?}");
1457
1458        assert_eq!(node_number(&doc, sec_one), "1");
1459        assert_eq!(node_number(&doc, sec_two), "2");
1460        assert_eq!(node_number(&doc, fig_one), "1");
1461        assert_eq!(node_number(&doc, fig_two), "2");
1462    }
1463
1464    #[test]
1465    fn section_target_index_carries_resolved_number() {
1466        let (doc, diags) = lower("= Intro <intro>\n\n== Methods <methods>\n");
1467        assert!(diags.is_empty(), "{diags:?}");
1468
1469        let mut sink: Vec<Diagnostic> = Vec::new();
1470        let index = build_label_index(&doc, &mut sink);
1471        assert!(sink.is_empty(), "{sink:?}");
1472
1473        assert_eq!(
1474            index.get("intro").map(|t| &t.kind),
1475            Some(&LabelTargetKind::Section {
1476                number: "1".to_owned()
1477            })
1478        );
1479        assert_eq!(
1480            index.get("methods").map(|t| &t.kind),
1481            Some(&LabelTargetKind::Section {
1482                number: "1.1".to_owned()
1483            })
1484        );
1485    }
1486
1487    #[test]
1488    fn level_three_numbers_correctly() {
1489        let (doc, diags) = lower("= A\n\n== B\n\n=== C\n\n== D\n\n= E\n");
1490        assert!(diags.is_empty(), "{diags:?}");
1491        let nums: Vec<String> = doc
1492            .nodes()
1493            .filter(|n| n.kind == NodeKind::Section)
1494            .filter_map(|n| match n.attributes.get("number") {
1495                Some(AttrValue::Str(s)) => Some(s.clone()),
1496                _ => None,
1497            })
1498            .collect();
1499        assert_eq!(nums, vec!["1", "1.1", "1.1.1", "1.2", "2"]);
1500    }
1501
1502    #[test]
1503    fn unknown_reference_suggests_nearest_label() {
1504        // A near-miss typo gets a machine-applicable "did you mean" fix:
1505        // replace the whole `@intrdo` token (sigil included) with `@intro`.
1506        let src = "= Intro <intro>\n\nsee @intrdo\n";
1507        let (doc, diags) = lower(src);
1508        let mos0033: Vec<&Diagnostic> = diags
1509            .iter()
1510            .filter(|d| d.def().code() == codes::MOS0033.code())
1511            .collect();
1512        assert_eq!(
1513            mos0033.len(),
1514            1,
1515            "expected exactly one MOS0033, got {diags:?}"
1516        );
1517        let d = mos0033[0];
1518        // Message and span are unchanged from the no-suggestion path.
1519        assert!(
1520            d.message().contains("`intrdo`"),
1521            "message should still name the missing label, got {:?}",
1522            d.message()
1523        );
1524        assert_eq!(
1525            d.span().map(|span| &src[span.start()..span.end()]),
1526            Some("@intrdo"),
1527            "MOS0033 span should still cover the bad reference exactly"
1528        );
1529        // Exactly one structured suggestion, replacing the full reference.
1530        let suggestions = d.suggestions();
1531        assert_eq!(
1532            suggestions.len(),
1533            1,
1534            "expected one nearest-label suggestion, got {suggestions:?}"
1535        );
1536        if let Some(suggestion) = suggestions.first() {
1537            assert_eq!(
1538                &src[suggestion.span.start()..suggestion.span.end()],
1539                "@intrdo",
1540                "suggestion should replace the whole `@` reference token"
1541            );
1542            assert_eq!(suggestion.replacement, "@intro");
1543            assert_eq!(
1544                apply_suggestion(src, suggestion),
1545                "= Intro <intro>\n\nsee @intro\n",
1546                "applying the fix should rewrite `@intrdo` to `@intro`"
1547            );
1548        }
1549        // The unresolved placeholder stays visible in the meantime.
1550        let reference_text = doc
1551            .nodes()
1552            .find(|n| n.kind == NodeKind::Reference)
1553            .and_then(|n| n.attributes.get("text"));
1554        assert_eq!(reference_text, Some(&AttrValue::Str("?intrdo?".to_owned())));
1555    }
1556
1557    #[test]
1558    fn unknown_reference_suggestion_breaks_ties_deterministically() {
1559        // `@intrx` sits one edit from both `intra` and `intro`. The tie
1560        // breaks on `(distance, label)`, so the single suggestion is always
1561        // the lexicographically smaller `@intra`.
1562        let src = "= A <intra>\n\n= B <intro>\n\nsee @intrx\n";
1563        let (_doc, diags) = lower(src);
1564        let mos0033: Vec<&Diagnostic> = diags
1565            .iter()
1566            .filter(|d| d.def().code() == codes::MOS0033.code())
1567            .collect();
1568        assert_eq!(mos0033.len(), 1, "got {diags:?}");
1569        if let Some(d) = mos0033.first() {
1570            let suggestions = d.suggestions();
1571            assert_eq!(
1572                suggestions.len(),
1573                1,
1574                "exactly one nearest-label suggestion, got {suggestions:?}"
1575            );
1576            if let Some(suggestion) = suggestions.first() {
1577                assert_eq!(
1578                    suggestion.replacement, "@intra",
1579                    "ties must resolve to the lexicographically smaller label"
1580                );
1581            }
1582        }
1583    }
1584
1585    #[test]
1586    fn unknown_reference_without_close_match_has_no_suggestion() {
1587        // An unrelated reference name is left without a guess.
1588        let src = "= Intro <intro>\n\nsee @conclusion\n";
1589        let (_doc, diags) = lower(src);
1590        let mos0033: Vec<&Diagnostic> = diags
1591            .iter()
1592            .filter(|d| d.def().code() == codes::MOS0033.code())
1593            .collect();
1594        assert_eq!(mos0033.len(), 1, "got {diags:?}");
1595        if let Some(d) = mos0033.first() {
1596            assert!(
1597                d.suggestions().is_empty(),
1598                "an unrelated label must not be suggested, got {:?}",
1599                d.suggestions()
1600            );
1601        }
1602    }
1603
1604    #[test]
1605    fn short_unknown_reference_has_no_suggestion() {
1606        // Conservative floor: references shorter than three bytes never get a
1607        // suggestion, even when a one-edit neighbour (`ax`) exists.
1608        let src = "= A <ax>\n\nsee @ab\n";
1609        let (_doc, diags) = lower(src);
1610        let mos0033: Vec<&Diagnostic> = diags
1611            .iter()
1612            .filter(|d| d.def().code() == codes::MOS0033.code())
1613            .collect();
1614        assert_eq!(mos0033.len(), 1, "got {diags:?}");
1615        if let Some(d) = mos0033.first() {
1616            assert!(
1617                d.suggestions().is_empty(),
1618                "short references must not be guessed, got {:?}",
1619                d.suggestions()
1620            );
1621        }
1622    }
1623
1624    #[test]
1625    fn unreferenceable_label_is_not_suggested() {
1626        // `#figure(label: "...")` / `#image(label: "...")` accept arbitrary
1627        // strings, so the index can hold a label the `@`-reference grammar
1628        // cannot spell. `@intro x` would not parse, so even this one-edit
1629        // match must be filtered out and produce no suggestion.
1630        let mut doc = Document::new(PathBuf::from("test.mos"));
1631        let _figure = make_node(&mut doc, NodeKind::Figure, Some("intro x"), None);
1632        let _reference = make_node(&mut doc, NodeKind::Reference, Some("introx"), None);
1633
1634        let mut diagnostics: Vec<Diagnostic> = Vec::new();
1635        let index = build_label_index(&doc, &mut diagnostics);
1636        let changed = rewrite_references(&mut doc, &index, &BTreeSet::new(), &mut diagnostics);
1637        assert!(!changed, "an unknown reference rewrites no text");
1638
1639        let mos0033: Vec<&Diagnostic> = diagnostics
1640            .iter()
1641            .filter(|d| d.def().code() == codes::MOS0033.code())
1642            .collect();
1643        assert_eq!(mos0033.len(), 1, "got {diagnostics:?}");
1644        if let Some(d) = mos0033.first() {
1645            assert!(
1646                d.suggestions().is_empty(),
1647                "an unreferenceable label must not be suggested, got {:?}",
1648                d.suggestions()
1649            );
1650        }
1651    }
1652}