Skip to main content

mos_eval/
pageref.rs

1//! Page-reference resolution and its layout fixpoint (issue #72).
2//!
3//! `@page(label)` resolves to the *printed page number* of `label`'s target.
4//! Unlike a section or figure number, that page number is only known after
5//! layout, so it cannot be resolved in the single lowering pass: a page
6//! reference's rendered width can shift pagination, which can move the target
7//! to a different page, which changes the number. The fixpoint drives layout
8//! repeatedly until the label→page map stabilizes.
9//!
10//! Responsibilities are split by which input each step needs:
11//!
12//! - [`validate_page_references`](mod@crate::resolve) runs at lower time, where the
13//!   label *index* exists, and reports an undeclared `@page(x)` as `MOS0033`,
14//!   exactly like an undeclared `@x`. (It lives in `resolve` next to the index.)
15//! - [`resolve_page_references`] runs each fixpoint iteration with a label→page
16//!   *map* from layout and rewrites each page reference's text to the number.
17//! - [`resolve_page_reference_fixpoint`] is the driver. Layout is *injected* as
18//!   a closure so this module keeps no `mos-layout` dependency (the one-way
19//!   crate flow holds) and the loop is unit-testable with a mock layout.
20
21use std::collections::BTreeMap;
22
23use mos_core::{AttrValue, Document, NodeId, NodeKind};
24
25/// Rewrite every `@page(label)` reference's visible text to its target's page
26/// number, drawn from `label_pages` (a label→1-based-page map produced by
27/// layout). Returns whether any text changed, so the [fixpoint
28/// driver](resolve_page_reference_fixpoint) can tell when the document settled.
29///
30/// A label absent from `label_pages` resolves to its `?label?` placeholder: an
31/// *undeclared* label was already reported as `MOS0033` at lower time, and a
32/// *declared* label whose target produced no content simply has no page. The
33/// placeholder is written every call, so a reference whose label *drops* out of
34/// a later map reverts from a stale number back to the placeholder rather than
35/// keeping it.
36///
37/// Idempotent and a pure function of the document's page-reference labels and
38/// `label_pages`: the text is re-derived from the map each call, never from the
39/// previously-written number, so repeated calls with the same map are no-ops
40/// and the result never depends on call history.
41pub fn resolve_page_references(
42    document: &mut Document,
43    label_pages: &BTreeMap<String, u32>,
44) -> bool {
45    let page_refs: Vec<NodeId> = document
46        .nodes()
47        .filter(|node| node.kind == NodeKind::PageReference)
48        .map(|node| node.id)
49        .collect();
50
51    let mut changed = false;
52    for id in page_refs {
53        let Some(node) = document.get(id) else {
54            continue;
55        };
56        let Some(AttrValue::Str(label)) = node.attributes.get("label").cloned() else {
57            continue;
58        };
59        // Re-derive the text every call: a present label resolves to its page
60        // number, an absent one to the `?label?` placeholder (matching the
61        // lowering fallback). Deriving unconditionally is what keeps a dropped
62        // label from leaving a stale number behind.
63        let resolved = AttrValue::Str(
64            label_pages
65                .get(&label)
66                .map_or_else(|| format!("?{label}?"), u32::to_string),
67        );
68        if let Some(node) = document.get_mut(id)
69            && node.attributes.get("text") != Some(&resolved)
70        {
71            node.attributes.insert("text".to_owned(), resolved);
72            changed = true;
73        }
74    }
75    changed
76}
77
78/// The result of driving page references to a fixpoint.
79#[derive(Clone, Copy, Eq, PartialEq, Debug)]
80pub enum PageFixpointOutcome {
81    /// The label→page map stabilized; the rendered page numbers are final.
82    Converged {
83        /// Resolve↔layout rounds run before the map settled.
84        iterations: u32,
85    },
86    /// The map never settled: it oscillated, or the iteration cap was hit.
87    /// The caller keeps the last computed page numbers and should report
88    /// `MOS0047`.
89    NotConverged {
90        /// Resolve↔layout rounds run before giving up.
91        iterations: u32,
92    },
93}
94
95/// Drive page-reference resolution to a fixpoint against an injected `layout`.
96///
97/// `layout` lays `document` out and returns its label→page map plus the full
98/// layout artifact `T`, so the caller keeps the final artifact without an extra
99/// pass. Each round resolves page references from the previous map, then
100/// re-lays-out; it converges when the map stops changing. Oscillation (a
101/// previously-seen map recurs) and exhausting `max_iterations` both yield
102/// [`NotConverged`](PageFixpointOutcome::NotConverged) with the most recent
103/// artifact.
104///
105/// Layout is a parameter rather than a direct call so `mos-eval` does not depend
106/// on `mos-layout` and the convergence logic can be tested with a mock.
107pub fn resolve_page_reference_fixpoint<T>(
108    document: &mut Document,
109    mut layout: impl FnMut(&Document) -> (BTreeMap<String, u32>, T),
110    max_iterations: u32,
111) -> (PageFixpointOutcome, T) {
112    let (mut map, mut artifact) = layout(document);
113    let mut seen = vec![map.clone()];
114    let mut iterations = 0;
115    while iterations < max_iterations {
116        iterations += 1;
117        if !resolve_page_references(document, &map) {
118            // No page-reference text changed: the document has settled.
119            return (PageFixpointOutcome::Converged { iterations }, artifact);
120        }
121        let (next_map, next_artifact) = layout(document);
122        artifact = next_artifact;
123        if next_map == map {
124            // The numbers we wrote match the layout they produced.
125            return (PageFixpointOutcome::Converged { iterations }, artifact);
126        }
127        if seen.contains(&next_map) {
128            // A previously-seen map recurred without converging: oscillation.
129            return (PageFixpointOutcome::NotConverged { iterations }, artifact);
130        }
131        seen.push(next_map.clone());
132        map = next_map;
133    }
134    (PageFixpointOutcome::NotConverged { iterations }, artifact)
135}
136
137#[cfg(test)]
138mod tests {
139    use std::collections::BTreeMap;
140    use std::path::PathBuf;
141
142    use mos_core::{AttrValue, Document, NodeKind};
143
144    use super::{PageFixpointOutcome, resolve_page_reference_fixpoint, resolve_page_references};
145    use crate::lower;
146
147    fn page_map(pairs: &[(&str, u32)]) -> BTreeMap<String, u32> {
148        pairs.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect()
149    }
150
151    fn page_ref_text(document: &Document, label: &str) -> Option<String> {
152        document
153            .nodes()
154            .filter(|n| n.kind == NodeKind::PageReference)
155            .find(|n| n.attributes.get("label") == Some(&AttrValue::Str(label.to_owned())))
156            .and_then(|n| match n.attributes.get("text") {
157                Some(AttrValue::Str(text)) => Some(text.clone()),
158                _ => None,
159            })
160    }
161
162    fn doc_with_one_page_ref() -> Document {
163        // The `?x?` placeholder survives lowering; the MOS0033 for the
164        // undeclared label is irrelevant to these fixpoint-driver tests.
165        lower("See @page(x) here.\n", &PathBuf::from("test.mos")).document
166    }
167
168    #[test]
169    fn resolve_writes_the_page_number_and_is_idempotent() {
170        let mut document = doc_with_one_page_ref();
171        let map = page_map(&[("x", 3)]);
172        assert!(resolve_page_references(&mut document, &map));
173        assert_eq!(page_ref_text(&document, "x"), Some("3".to_owned()));
174        // Re-running with the same map changes nothing.
175        assert!(!resolve_page_references(&mut document, &map));
176    }
177
178    #[test]
179    fn resolve_reverts_to_placeholder_when_a_label_drops_from_the_map() {
180        // The text is a pure function of the current map: if a label that was
181        // resolved to a page disappears from a later map, its stale number must
182        // revert to the placeholder rather than linger.
183        let mut document = doc_with_one_page_ref();
184        assert!(resolve_page_references(
185            &mut document,
186            &page_map(&[("x", 3)])
187        ));
188        assert_eq!(page_ref_text(&document, "x"), Some("3".to_owned()));
189
190        assert!(resolve_page_references(&mut document, &page_map(&[])));
191        assert_eq!(page_ref_text(&document, "x"), Some("?x?".to_owned()));
192    }
193
194    #[test]
195    fn resolve_leaves_a_label_with_no_page_as_placeholder() {
196        let mut document = doc_with_one_page_ref();
197        assert!(!resolve_page_references(&mut document, &page_map(&[])));
198        assert_eq!(page_ref_text(&document, "x"), Some("?x?".to_owned()));
199    }
200
201    #[test]
202    fn fixpoint_converges_when_the_map_is_stable() {
203        let mut document = doc_with_one_page_ref();
204        let (outcome, ()) =
205            resolve_page_reference_fixpoint(&mut document, |_doc| (page_map(&[("x", 2)]), ()), 8);
206        assert_eq!(outcome, PageFixpointOutcome::Converged { iterations: 1 });
207        assert_eq!(page_ref_text(&document, "x"), Some("2".to_owned()));
208    }
209
210    #[test]
211    fn fixpoint_converges_immediately_with_no_page_references() {
212        let mut document = lower("plain paragraph\n", &PathBuf::from("test.mos")).document;
213        let (outcome, ()) =
214            resolve_page_reference_fixpoint(&mut document, |_doc| (page_map(&[]), ()), 8);
215        assert_eq!(outcome, PageFixpointOutcome::Converged { iterations: 1 });
216    }
217
218    #[test]
219    fn fixpoint_reports_non_convergence_on_oscillation() {
220        // A mock layout that flip-flops the page between two values: resolving
221        // never settles, and the first map recurs, so the driver gives up.
222        let mut document = doc_with_one_page_ref();
223        let mut round = 0_u32;
224        let (outcome, ()) = resolve_page_reference_fixpoint(
225            &mut document,
226            |_doc| {
227                round += 1;
228                // maps: round 1 -> {x:1}, 2 -> {x:2}, 3 -> {x:1} (recurs)
229                let page = if round % 2 == 1 { 1 } else { 2 };
230                (page_map(&[("x", page)]), ())
231            },
232            8,
233        );
234        assert_eq!(outcome, PageFixpointOutcome::NotConverged { iterations: 2 });
235    }
236
237    #[test]
238    fn fixpoint_reports_non_convergence_at_the_iteration_cap() {
239        // A mock layout whose page strictly increases every call never repeats
240        // and never stabilizes, so the cap is the only stop.
241        let mut document = doc_with_one_page_ref();
242        let mut page = 0_u32;
243        let (outcome, ()) = resolve_page_reference_fixpoint(
244            &mut document,
245            |_doc| {
246                page += 1;
247                (page_map(&[("x", page)]), ())
248            },
249            4,
250        );
251        assert_eq!(outcome, PageFixpointOutcome::NotConverged { iterations: 4 });
252    }
253}