Skip to main content

rd_ast/
raw_classification.rs

1//! Classification of the lowering's remaining `Raw` exception, pinned
2//! against supported RDS-producer output.
3//!
4//! This is a regression guard for the current RDS lowering, not the final
5//! semantic contract for USERMACRO nodes. A future USERMACRO representation
6//! must revise this guard together with the lowering change.
7
8use crate::{RawRdNode, RawRdValue, RdNode};
9
10/// The currently accepted and unexpected shapes of an RDS-lowered `Raw` node.
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum RawNodeClassification {
14    /// The empirically observed USERMACRO definition shape across supported
15    /// RDS producers (installed help databases and direct `saveRDS` of
16    /// `parse_Rd` output): one text child, no option, and the exact
17    /// `srcref`/`macro` attribute shapes checked by [`classify_raw_node`].
18    ExpectedUserMacroDefinition,
19    /// A Raw node outside that pinned exception.
20    Unexpected(UnexpectedRawNode),
21}
22
23/// Details explaining why a Raw node did not match the pinned shape.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct UnexpectedRawNode {
26    tag: Option<String>,
27    reason: RawNodeReason,
28    offending_attributes: Vec<String>,
29}
30
31impl UnexpectedRawNode {
32    pub fn tag(&self) -> Option<&str> {
33        self.tag.as_deref()
34    }
35    pub fn reason(&self) -> &RawNodeReason {
36        &self.reason
37    }
38    pub fn offending_attributes(&self) -> &[String] {
39        &self.offending_attributes
40    }
41}
42
43/// Coarse structural reason for an unexpected Raw node.
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum RawNodeReason {
47    WrongTag,
48    OptionPresent,
49    ChildrenShape,
50    PayloadPresent,
51    AttributeSet,
52    AttributeValueShape,
53}
54
55/// Classifies a Raw node against the RDS-producer-backed USERMACRO lowering
56/// guard.
57pub fn classify_raw_node(raw: &RawRdNode) -> RawNodeClassification {
58    let names: Vec<&str> = raw
59        .attributes()
60        .iter()
61        .map(|attribute| attribute.name())
62        .collect();
63    let unexpected_names: Vec<String> = names
64        .iter()
65        .filter(|name| **name != "srcref" && **name != "macro")
66        .map(|name| (*name).to_string())
67        .collect();
68
69    if raw.tag() != Some("USERMACRO") {
70        let all_names = names.iter().map(|name| (*name).to_string()).collect();
71        return unexpected(raw, RawNodeReason::WrongTag, all_names);
72    }
73    if raw.option().is_some() {
74        let mut offenders = unexpected_names;
75        offenders.push("Rd_option".to_string());
76        return unexpected(raw, RawNodeReason::OptionPresent, offenders);
77    }
78    if raw.payload().is_some() {
79        return unexpected(raw, RawNodeReason::PayloadPresent, unexpected_names);
80    }
81    if raw.children().len() != 1 || !matches!(raw.children()[0], RdNode::Text(_)) {
82        return unexpected(raw, RawNodeReason::ChildrenShape, unexpected_names);
83    }
84    if names.len() != 2
85        || names.iter().filter(|name| **name == "srcref").count() != 1
86        || names.iter().filter(|name| **name == "macro").count() != 1
87    {
88        let mut offenders = unexpected_names;
89        for name in ["srcref", "macro"] {
90            let count = names.iter().filter(|candidate| **candidate == name).count();
91            if count == 0 {
92                offenders.push(format!("missing:{name}"));
93            } else if count > 1 {
94                offenders.extend(std::iter::repeat_n(name.to_string(), count));
95            }
96        }
97        return unexpected(raw, RawNodeReason::AttributeSet, offenders);
98    }
99
100    let srcref = raw
101        .attributes()
102        .iter()
103        .find(|attribute| attribute.name() == "srcref");
104    let macro_attribute = raw
105        .attributes()
106        .iter()
107        .find(|attribute| attribute.name() == "macro");
108    let valid_srcref = srcref.is_some_and(|attribute| valid_srcref_value(attribute.value()));
109    let valid_macro = macro_attribute.is_some_and(|attribute| {
110        matches!(attribute.value().value(), RawRdValue::Character(values) if values.len() == 1 && values[0].is_some())
111            && attribute.value().attributes().is_empty()
112    });
113    if !valid_srcref || !valid_macro {
114        let malformed = raw
115            .attributes()
116            .iter()
117            .filter(|attribute| {
118                (attribute.name() == "srcref" && !valid_srcref)
119                    || (attribute.name() == "macro" && !valid_macro)
120            })
121            .map(|attribute| attribute.name().to_string())
122            .collect();
123        return unexpected(raw, RawNodeReason::AttributeValueShape, malformed);
124    }
125
126    RawNodeClassification::ExpectedUserMacroDefinition
127}
128
129fn valid_srcref_value(object: &crate::RawRdObject) -> bool {
130    let valid_integer = matches!(object.value(), RawRdValue::Integer(values) if values.len() == 6 && values.iter().all(Option::is_some));
131    let attributes = object.attributes();
132    if !valid_integer || attributes.len() != 2 {
133        return false;
134    }
135    let srcfile = attributes
136        .iter()
137        .find(|attribute| attribute.name() == "srcfile");
138    let class = attributes
139        .iter()
140        .find(|attribute| attribute.name() == "class");
141    // Installed help databases persist the srcfile environment through a
142    // reference hook; a direct saveRDS of parse_Rd output serializes it as an
143    // ordinary (non-singleton) environment. Both are supported RDS-producer
144    // provenances. Singleton environments never appear as a srcfile. The
145    // decoder collapses package and namespace environments into the same
146    // Other handle, so this arm nominally admits them too; that synthetic
147    // provenance is tolerated because a real srcfile is never one and any
148    // system-macro collapse still requires structural definition and
149    // expansion validation that runs after this guard.
150    srcfile.is_some_and(|attribute| {
151        attribute.value().attributes().is_empty()
152            && (matches!(attribute.value().value(), RawRdValue::Persisted(values) if values.len() == 1 && values[0].is_some())
153                || matches!(
154                    attribute.value().value(),
155                    RawRdValue::Environment(crate::RawRdEnvironment::Other)
156                ))
157    }) && class.is_some_and(|attribute| {
158        attribute.value().attributes().is_empty()
159            && matches!(attribute.value().value(), RawRdValue::Character(values) if values.len() == 1 && values[0].as_deref() == Some("srcref"))
160    })
161}
162
163fn unexpected(
164    raw: &RawRdNode,
165    reason: RawNodeReason,
166    offending_attributes: Vec<String>,
167) -> RawNodeClassification {
168    RawNodeClassification::Unexpected(UnexpectedRawNode {
169        tag: raw.tag().map(str::to_string),
170        reason,
171        offending_attributes,
172    })
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::{RawRdValue, producer};
179
180    fn text() -> RdNode {
181        RdNode::Text("body".to_string())
182    }
183    fn attr(name: &str, value: RawRdValue) -> crate::RdAttribute {
184        producer::raw_attribute(name.to_string(), producer::raw_object(value, Vec::new()))
185    }
186    fn srcref_with(srcfile_value: RawRdValue) -> crate::RdAttribute {
187        let srcfile = producer::raw_attribute(
188            "srcfile".into(),
189            producer::raw_object(srcfile_value, Vec::new()),
190        );
191        let class = producer::raw_attribute(
192            "class".into(),
193            producer::raw_object(
194                RawRdValue::Character(vec![Some("srcref".into())]),
195                Vec::new(),
196            ),
197        );
198        producer::raw_attribute(
199            "srcref".into(),
200            producer::raw_object(RawRdValue::Integer(vec![Some(1); 6]), vec![srcfile, class]),
201        )
202    }
203    fn srcref() -> crate::RdAttribute {
204        srcref_with(RawRdValue::Persisted(vec![Some("env::1".into())]))
205    }
206    fn usermacro(
207        attributes: Vec<crate::RdAttribute>,
208        option: Option<Vec<RdNode>>,
209        children: Vec<RdNode>,
210    ) -> RawRdNode {
211        producer::raw_node(Some("USERMACRO".into()), option, children, None, attributes)
212    }
213
214    #[test]
215    fn accepts_pinned_usermacro_shape() {
216        let raw = usermacro(
217            vec![
218                srcref(),
219                attr("macro", RawRdValue::Character(vec![Some(r"\eval".into())])),
220            ],
221            None,
222            vec![text()],
223        );
224        assert_eq!(
225            classify_raw_node(&raw),
226            RawNodeClassification::ExpectedUserMacroDefinition
227        );
228    }
229
230    #[test]
231    fn accepts_both_supported_srcfile_provenances() {
232        use crate::RawRdEnvironment;
233        let environment = usermacro(
234            vec![
235                srcref_with(RawRdValue::Environment(RawRdEnvironment::Other)),
236                attr("macro", RawRdValue::Character(vec![Some(r"\eval".into())])),
237            ],
238            None,
239            vec![text()],
240        );
241        assert_eq!(
242            classify_raw_node(&environment),
243            RawNodeClassification::ExpectedUserMacroDefinition
244        );
245        for singleton in [
246            RawRdEnvironment::Global,
247            RawRdEnvironment::Base,
248            RawRdEnvironment::Empty,
249        ] {
250            let rejected = usermacro(
251                vec![
252                    srcref_with(RawRdValue::Environment(singleton)),
253                    attr("macro", RawRdValue::Character(vec![Some(r"\eval".into())])),
254                ],
255                None,
256                vec![text()],
257            );
258            assert!(matches!(
259                classify_raw_node(&rejected),
260                RawNodeClassification::Unexpected(UnexpectedRawNode {
261                    reason: RawNodeReason::AttributeValueShape,
262                    ..
263                })
264            ));
265        }
266    }
267
268    #[test]
269    fn payload_is_rejected_from_pinned_usermacro_shape() {
270        let raw = producer::raw_node(
271            Some("USERMACRO".into()),
272            None,
273            vec![text()],
274            Some(RawRdValue::Null),
275            vec![
276                srcref(),
277                attr("macro", RawRdValue::Character(vec![Some(r"\eval".into())])),
278            ],
279        );
280        assert!(matches!(
281            classify_raw_node(&raw),
282            RawNodeClassification::Unexpected(UnexpectedRawNode {
283                reason: RawNodeReason::PayloadPresent,
284                ..
285            })
286        ));
287    }
288
289    #[test]
290    fn rejects_wrong_tag_extra_attribute_wrong_value_and_option() {
291        let base = || {
292            vec![
293                srcref(),
294                attr("macro", RawRdValue::Character(vec![Some(r"\eval".into())])),
295            ]
296        };
297        let wrong_tag = producer::raw_node(Some("OTHER".into()), None, vec![text()], None, base());
298        assert!(matches!(
299            classify_raw_node(&wrong_tag),
300            RawNodeClassification::Unexpected(UnexpectedRawNode {
301                reason: RawNodeReason::WrongTag,
302                ..
303            })
304        ));
305        let extra = usermacro(
306            vec![
307                srcref(),
308                attr("macro", RawRdValue::Character(vec![Some(r"\eval".into())])),
309                attr("extra", RawRdValue::Null),
310            ],
311            None,
312            vec![text()],
313        );
314        assert!(matches!(
315            classify_raw_node(&extra),
316            RawNodeClassification::Unexpected(UnexpectedRawNode {
317                reason: RawNodeReason::AttributeSet,
318                ..
319            })
320        ));
321        let wrong_value = usermacro(
322            vec![srcref(), attr("macro", RawRdValue::Integer(vec![Some(1)]))],
323            None,
324            vec![text()],
325        );
326        assert!(matches!(
327            classify_raw_node(&wrong_value),
328            RawNodeClassification::Unexpected(UnexpectedRawNode {
329                reason: RawNodeReason::AttributeValueShape,
330                ..
331            })
332        ));
333        let with_option = usermacro(base(), Some(vec![text()]), vec![text()]);
334        let RawNodeClassification::Unexpected(details) = classify_raw_node(&with_option) else {
335            unreachable!("option should be unexpected")
336        };
337        assert!(
338            details
339                .offending_attributes()
340                .contains(&"Rd_option".to_string())
341        );
342    }
343
344    #[test]
345    fn reports_missing_and_duplicate_required_attributes() {
346        let missing = usermacro(
347            vec![attr("macro", RawRdValue::Character(vec![Some("x".into())]))],
348            None,
349            vec![text()],
350        );
351        let RawNodeClassification::Unexpected(details) = classify_raw_node(&missing) else {
352            unreachable!("missing attribute should be unexpected")
353        };
354        assert!(
355            details
356                .offending_attributes()
357                .contains(&"missing:srcref".to_string())
358        );
359
360        let duplicate = usermacro(
361            vec![
362                srcref(),
363                attr("macro", RawRdValue::Character(vec![Some("x".into())])),
364                attr("macro", RawRdValue::Character(vec![Some("y".into())])),
365            ],
366            None,
367            vec![text()],
368        );
369        let RawNodeClassification::Unexpected(details) = classify_raw_node(&duplicate) else {
370            unreachable!("duplicate attribute should be unexpected")
371        };
372        assert_eq!(
373            details.offending_attributes(),
374            &["macro".to_string(), "macro".to_string()]
375        );
376    }
377}