Skip to main content

rdx_transform/transforms/
cross_ref_resolve.rs

1use rdx_ast::*;
2
3use crate::transforms::auto_number::{NumberEntry, NumberRegistry};
4use crate::{Transform, synthetic_pos};
5
6// ---------------------------------------------------------------------------
7// Transform
8// ---------------------------------------------------------------------------
9
10/// Resolves [`Node::CrossRef`] nodes by looking up their `target` label in a
11/// [`NumberRegistry`] produced by [`crate::AutoNumber`].
12///
13/// For the "web" target a `CrossRef` is replaced by a [`Node::Link`] whose
14/// `url` is `#<target>` and whose display text is e.g. `"Figure 1"`.
15///
16/// For the "print" target the node is replaced by a plain [`Node::Text`]
17/// containing the same display text (page numbers would be added by a
18/// downstream typesetter).
19///
20/// If the target label is **not** found in the registry the `CrossRef` node is
21/// left in place unchanged so that downstream tools can handle it or report the
22/// error.
23///
24/// # Example
25///
26/// ```rust
27/// use rdx_transform::{AutoNumber, CrossRefResolve, Transform, parse};
28///
29/// let mut root = parse(
30///     "<Figure id=\"fig:arch\">\n</Figure>\n\
31///      See {@fig:arch}.\n",
32/// );
33/// let numberer = AutoNumber::new();
34/// numberer.transform(&mut root, "");
35/// let registry = numberer.registry().entries.clone();
36/// let resolver = CrossRefResolve::new(
37///     rdx_transform::NumberRegistry { entries: registry },
38///     "web",
39/// );
40/// resolver.transform(&mut root, "");
41/// ```
42pub struct CrossRefResolve {
43    pub registry: NumberRegistry,
44    /// Output target: `"web"` or `"print"`.
45    pub target: String,
46}
47
48impl CrossRefResolve {
49    pub fn new(registry: NumberRegistry, target: impl Into<String>) -> Self {
50        CrossRefResolve {
51            registry,
52            target: target.into(),
53        }
54    }
55}
56
57impl Transform for CrossRefResolve {
58    fn name(&self) -> &str {
59        "cross-ref-resolve"
60    }
61
62    fn transform(&self, root: &mut Root, _source: &str) {
63        resolve_nodes(&mut root.children, &self.registry, &self.target);
64    }
65}
66
67// ---------------------------------------------------------------------------
68// Display text helper
69// ---------------------------------------------------------------------------
70
71fn display_text(entry: &NumberEntry) -> String {
72    format!("{} {}", entry.kind, entry.number)
73}
74
75// ---------------------------------------------------------------------------
76// Recursive walker
77// ---------------------------------------------------------------------------
78
79fn resolve_nodes(nodes: &mut [Node], registry: &NumberRegistry, target: &str) {
80    let mut i = 0;
81    while i < nodes.len() {
82        let replacement = if let Node::CrossRef(ref cr) = nodes[i] {
83            if let Some(entry) = registry.entries.get(&cr.target) {
84                let text = display_text(entry);
85                if target == "print" {
86                    Some(Node::Text(TextNode {
87                        value: text,
88                        position: synthetic_pos(),
89                    }))
90                } else {
91                    // "web" (or any non-print target): produce a Link node.
92                    Some(Node::Link(LinkNode {
93                        url: format!("#{}", cr.target),
94                        title: None,
95                        children: vec![Node::Text(TextNode {
96                            value: text,
97                            position: synthetic_pos(),
98                        })],
99                        position: synthetic_pos(),
100                    }))
101                }
102            } else {
103                None // Not found — leave the CrossRef in place.
104            }
105        } else {
106            None
107        };
108
109        if let Some(new_node) = replacement {
110            nodes[i] = new_node;
111        } else {
112            // Recurse into children of non-CrossRef nodes.
113            if let Some(children) = nodes[i].children_mut() {
114                resolve_nodes(children, registry, target);
115            }
116        }
117        i += 1;
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Tests
123// ---------------------------------------------------------------------------
124
125#[cfg(test)]
126mod tests {
127    use std::collections::HashMap;
128
129    use super::*;
130    use crate::transforms::auto_number::NumberEntry;
131
132    fn make_registry(entries: Vec<(&str, &str, &str)>) -> NumberRegistry {
133        let mut map = HashMap::new();
134        for (label, kind, number) in entries {
135            map.insert(
136                label.to_string(),
137                NumberEntry {
138                    kind: kind.to_string(),
139                    number: number.to_string(),
140                    title: None,
141                },
142            );
143        }
144        NumberRegistry { entries: map }
145    }
146
147    fn cross_ref_node(target: &str) -> Node {
148        Node::CrossRef(CrossRefNode {
149            target: target.to_string(),
150            position: Position {
151                start: Point {
152                    line: 1,
153                    column: 1,
154                    offset: 0,
155                },
156                end: Point {
157                    line: 1,
158                    column: 1,
159                    offset: 0,
160                },
161            },
162        })
163    }
164
165    fn root_with_children(children: Vec<Node>) -> Root {
166        Root {
167            node_type: RootType::Root,
168            frontmatter: None,
169            children,
170            position: Position {
171                start: Point {
172                    line: 1,
173                    column: 1,
174                    offset: 0,
175                },
176                end: Point {
177                    line: 1,
178                    column: 1,
179                    offset: 0,
180                },
181            },
182        }
183    }
184
185    #[test]
186    fn resolves_known_ref_to_link_for_web() {
187        let registry = make_registry(vec![("fig:arch", "Figure", "1")]);
188        let resolver = CrossRefResolve::new(registry, "web");
189
190        let mut root = root_with_children(vec![cross_ref_node("fig:arch")]);
191        resolver.transform(&mut root, "");
192
193        match &root.children[0] {
194            Node::Link(l) => {
195                assert_eq!(l.url, "#fig:arch");
196                match &l.children[0] {
197                    Node::Text(t) => assert_eq!(t.value, "Figure 1"),
198                    other => panic!("Expected text child, got {:?}", other),
199                }
200            }
201            other => panic!("Expected Link, got {:?}", other),
202        }
203    }
204
205    #[test]
206    fn resolves_known_ref_to_text_for_print() {
207        let registry = make_registry(vec![("thm:main", "Theorem", "3")]);
208        let resolver = CrossRefResolve::new(registry, "print");
209
210        let mut root = root_with_children(vec![cross_ref_node("thm:main")]);
211        resolver.transform(&mut root, "");
212
213        match &root.children[0] {
214            Node::Text(t) => assert_eq!(t.value, "Theorem 3"),
215            other => panic!("Expected Text, got {:?}", other),
216        }
217    }
218
219    #[test]
220    fn unknown_ref_left_unchanged() {
221        let registry = make_registry(vec![]);
222        let resolver = CrossRefResolve::new(registry, "web");
223
224        let mut root = root_with_children(vec![cross_ref_node("fig:unknown")]);
225        resolver.transform(&mut root, "");
226
227        assert!(
228            matches!(&root.children[0], Node::CrossRef(cr) if cr.target == "fig:unknown"),
229            "Expected CrossRef to remain, got {:?}",
230            root.children[0]
231        );
232    }
233
234    #[test]
235    fn resolves_cross_ref_nested_in_paragraph() {
236        let registry = make_registry(vec![("eq:euler", "Equation", "2")]);
237        let resolver = CrossRefResolve::new(registry, "web");
238
239        let para = Node::Paragraph(StandardBlockNode {
240            depth: None,
241            ordered: None,
242            checked: None,
243            id: None,
244            children: vec![cross_ref_node("eq:euler")],
245            position: Position {
246                start: Point {
247                    line: 1,
248                    column: 1,
249                    offset: 0,
250                },
251                end: Point {
252                    line: 1,
253                    column: 1,
254                    offset: 0,
255                },
256            },
257        });
258
259        let mut root = root_with_children(vec![para]);
260        resolver.transform(&mut root, "");
261
262        match &root.children[0] {
263            Node::Paragraph(p) => match &p.children[0] {
264                Node::Link(l) => {
265                    assert_eq!(l.url, "#eq:euler");
266                }
267                other => panic!("Expected link inside paragraph, got {:?}", other),
268            },
269            other => panic!("Expected paragraph, got {:?}", other),
270        }
271    }
272
273    #[test]
274    fn multiple_refs_resolved_independently() {
275        let registry = make_registry(vec![("fig:a", "Figure", "1"), ("fig:b", "Figure", "2")]);
276        let resolver = CrossRefResolve::new(registry, "web");
277
278        let mut root = root_with_children(vec![
279            cross_ref_node("fig:a"),
280            cross_ref_node("fig:b"),
281            cross_ref_node("fig:unknown"),
282        ]);
283        resolver.transform(&mut root, "");
284
285        assert!(matches!(&root.children[0], Node::Link(l) if l.url == "#fig:a"));
286        assert!(matches!(&root.children[1], Node::Link(l) if l.url == "#fig:b"));
287        assert!(matches!(&root.children[2], Node::CrossRef(_)));
288    }
289}