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