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}