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}