rd-ast 0.0.1

Canonical, producer-agnostic AST for R's Rd (documentation) format
Documentation
use super::*;

/// The extra-attribute fixture (`rd_extra_attr_v*.rds`, generated by
/// section 4c of `tests/fixtures/generate_fixtures.R`) pins both sides
/// of the attribute policy at once:
///
/// - `\title` carries an unknown `mystery` attribute (plus srcref), so
///   it must fall back to Raw, preserving BOTH attributes -- srcref is
///   only discarded on the structured path, never stripped from Raw.
/// - `\name` carries `class` and `\alias` carries `macros`: those
///   names are only discarded at the ROOT, so a child carrying them
///   must go Raw too.
/// - `\description` carries only `Rd_tag` + srcref and must stay
///   Tagged.
/// - the root carries an unknown `rootextra` attribute (plus
///   `class = "Rd"`, srcref, and `macros`); root attributes are all
///   discarded without affecting lowering, so `lower_r_object` must
///   succeed.
#[test]
fn extra_attributes_force_raw_and_preserve_everything() {
    for version in [2, 3] {
        let root = fixture(&format!("rd_extra_attr_v{version}.rds"));
        assert!(
            root.attributes().get("rootextra").is_some(),
            "fixture root should carry the unknown rootextra attribute"
        );
        let doc = lower_r_object(&root).expect("root attributes never fail the lowering");

        let raw_by_tag = |tag: &str| {
            doc.nodes().iter().find_map(|node| match node {
                RdNode::Raw(raw) if raw.tag() == Some(tag) => Some(raw),
                _ => None,
            })
        };
        let attribute_names = |raw: &crate::RawRdNode| {
            raw.attributes()
                .iter()
                .map(|attribute| attribute.name().to_string())
                .collect::<Vec<_>>()
        };

        let title = raw_by_tag(r"\title").expect(r"\title goes Raw (unknown attribute)");
        let title_attrs = attribute_names(title);
        assert!(
            title_attrs.contains(&"mystery".to_string()),
            "{title_attrs:?}"
        );
        assert!(
            title_attrs.contains(&"srcref".to_string()),
            "{title_attrs:?}"
        );
        let srcref = title
            .attributes()
            .iter()
            .find(|attribute| attribute.name() == "srcref")
            .expect("Raw title preserves srcref");
        assert!(
            matches!(srcref.value().value(), RawRdValue::Integer(_)),
            "srcref should preserve its integer vector: {srcref:?}"
        );
        let srcfile = srcref
            .value()
            .attributes()
            .iter()
            .find(|attribute| attribute.name() == "srcfile")
            .expect("srcref preserves its nested srcfile attribute");
        assert!(
            matches!(srcfile.value().value(), RawRdValue::Environment(_)),
            "srcfile should preserve its environment handle: {srcfile:?}"
        );

        let name = raw_by_tag(r"\name").expect(r"\name goes Raw (class is root-only)");
        assert!(attribute_names(name).contains(&"class".to_string()));

        let alias = raw_by_tag(r"\alias").expect(r"\alias goes Raw (macros is root-only)");
        assert!(attribute_names(alias).contains(&"macros".to_string()));

        assert!(
            doc.nodes().iter().any(|node| matches!(
                node,
                RdNode::Tagged(tagged) if tagged.tag() == &RdTag::Description
            )),
            r"\description (Rd_tag + srcref only) stays Tagged"
        );
    }
}

#[test]
fn valid_dynamic_flag_on_list_nodes_lowers_structurally() {
    let details = dynamic_fixture_node(r"\description");
    let details = with_value(&details, RValue::List(Vec::new()));
    let dynamic_flag = details
        .attributes()
        .iter()
        .find(|attribute| attribute.name().as_str() == "dynamicFlag")
        .expect("dynamicFlag attribute");
    let scalar_18 = Attribute::new(dynamic_flag.name().clone(), RObject::from_parts(RValue::Integer(vec![Some(18)]), Default::default()));
    let context = NodeContext {
        tag: Some(r"\details"),
        value: details.value(),
        attributes: details.attributes(),
    };
    assert_eq!(
        classify_attribute(&context, &scalar_18),
        AttributeDisposition::Discard
    );
    let document = lower_r_object(&RObject::from_parts(RValue::List(vec![details]), Default::default()))
    .expect("lower document");
    assert!(matches!(document.nodes(), [RdNode::Tagged(_)]));

    let sexpr = dynamic_fixture_node(r"\Sexpr");
    let document = lower_r_object(&RObject::from_parts(RValue::List(vec![sexpr]), Default::default()))
    .expect("lower document");
    assert!(matches!(document.nodes(), [RdNode::Tagged(_)]));
}

#[test]
fn dynamic_flag_with_srcref_is_discardable() {
    let description = dynamic_fixture_node(r"\description");
    assert!(description.attributes().get("srcref").is_some());
    let document = lower_r_object(&RObject::from_parts(RValue::List(vec![description]), Default::default()))
    .expect("lower document");
    assert!(matches!(document.nodes(), [RdNode::Tagged(_)]));
}

fn class_fixture_attribute() -> Attribute {
    let root = fixture("rd_extra_attr_v2.rds");
    root.attributes()
        .iter()
        .find(|attribute| attribute.name().as_str() == "class")
        .expect("class attribute")
        .clone()
}

fn attribute_with_value(template: &Attribute, value: RValue) -> Attribute {
    Attribute::new(
        template.name().clone(),
        RObject::from_parts(value, Default::default()),
    )
}

fn fixture_attribute(name: &str) -> Attribute {
    fn find(object: &RObject, name: &str) -> Option<Attribute> {
        if let Some(attribute) = object
            .attributes()
            .iter()
            .find(|attribute| attribute.name().as_str() == name)
        {
            return Some(attribute.clone());
        }
        match object.value() {
            RValue::List(objects) => objects.iter().find_map(|object| find(object, name)),
            _ => None,
        }
    }

    [
        "rd_minimal_v2.rds",
        "rd_options_v2.rds",
        "rd_extra_attr_v2.rds",
    ]
    .into_iter()
    .find_map(|fixture_name| find(&fixture(fixture_name), name))
    .expect("fixture attribute")
}

fn invalid_string() -> RStr {
    RStr::Value {
        bytes: Arc::from(&b"\xff"[..]),
        encoding: REncoding::Bytes,
        native_encoding: None,
    }
}

fn attribute(name: &str, value: RValue) -> Attribute {
    attribute_with_value(&fixture_attribute(name), value)
}

fn with_nested_metadata_attributes(template: &RObject, metadata_names: &[&str]) -> RObject {
    let nested_attributes = fixture("rd_options_v2.rds")
        .attributes()
        .get("srcref")
        .expect("fixture srcref attribute")
        .attributes()
        .clone();
    let attributes = template
        .attributes()
        .iter()
        .map(|attribute| {
            let mut attribute = attribute.clone();
            if metadata_names
                .iter()
                .any(|name| *name == attribute.name().as_str())
            {
                let (value, _) = attribute.value().clone().into_parts();
                attribute = Attribute::new(
                    attribute.name().clone(),
                    RObject::from_parts(value, nested_attributes.clone()),
                );
            }
            attribute
        })
        .collect();

    RObject::from_parts(template.value().clone(), Attributes::new(attributes))
}

fn assert_raw_metadata_has_nested_attributes(node: &RdNode, names: &[&str]) {
    let RdNode::Raw(raw) = node else {
        panic!("expected Raw node, got {node:?}");
    };
    for name in names {
        let attribute = raw
            .attributes()
            .iter()
            .find(|attribute| attribute.name() == *name)
            .expect("metadata attribute preserved in Raw");
        assert!(!attribute.value().attributes().is_empty());
    }
}

#[test]
fn valid_rd_class_is_discardable_only_on_list_nodes() {
    let class = class_fixture_attribute();
    let root = fixture("rd_extra_attr_v2.rds");
    let list_context = NodeContext {
        tag: Some("LIST"),
        value: &RValue::List(Vec::new()),
        attributes: root.attributes(),
    };
    assert_eq!(
        classify_attribute(&list_context, &class),
        AttributeDisposition::Discard
    );

    let name_context = NodeContext {
        tag: Some(r"\name"),
        value: &RValue::List(Vec::new()),
        attributes: root.attributes(),
    };
    assert_eq!(
        classify_attribute(&name_context, &class),
        AttributeDisposition::Reject
    );

    let non_list_context = NodeContext {
        tag: Some("LIST"),
        value: &RValue::Character(Vec::new()),
        attributes: root.attributes(),
    };
    assert_eq!(
        classify_attribute(&non_list_context, &class),
        AttributeDisposition::Reject
    );
}

#[test]
fn classed_list_can_coexist_with_discardable_metadata() {
    let class = class_fixture_attribute();
    let description = dynamic_fixture_node(r"\description");
    let dynamic_flag = description
        .attributes()
        .iter()
        .find(|attribute| attribute.name().as_str() == "dynamicFlag")
        .expect("dynamicFlag attribute");
    let srcref = description
        .attributes()
        .iter()
        .find(|attribute| attribute.name().as_str() == "srcref")
        .expect("srcref attribute");
    let context = NodeContext {
        tag: Some("LIST"),
        value: &RValue::List(Vec::new()),
        attributes: description.attributes(),
    };
    assert_eq!(
        classify_attribute(&context, &class),
        AttributeDisposition::Discard
    );
    assert_eq!(
        classify_attribute(&context, dynamic_flag),
        AttributeDisposition::Discard
    );
    assert_eq!(
        classify_attribute(&context, srcref),
        AttributeDisposition::Discard
    );
}

#[test]
fn invalid_list_class_values_and_metadata_force_rejection() {
    let class = class_fixture_attribute();
    let root = fixture("rd_extra_attr_v2.rds");
    let list_context = NodeContext {
        tag: Some("LIST"),
        value: &RValue::List(Vec::new()),
        attributes: root.attributes(),
    };
    let invalid = [
        attribute_with_value(&class, RValue::Character(Vec::new())),
        attribute_with_value(
            &class,
            RValue::Character(vec![
                match class.value().value() {
                    RValue::Character(values) => values[0].clone(),
                    _ => unreachable!("class is character"),
                },
                match class.value().value() {
                    RValue::Character(values) => values[0].clone(),
                    _ => unreachable!("class is character"),
                },
            ]),
        ),
        attribute_with_value(&class, RValue::Integer(vec![Some(1)])),
        attribute_with_value(&class, RValue::Character(vec![RStr::Na])),
    ];
    for attribute in invalid {
        assert_eq!(
            classify_attribute(&list_context, &attribute),
            AttributeDisposition::Reject
        );
    }

    // An additional producer attribute such as Rdfile is not accepted by
    // the LIST policy; it must keep the node on the lossless Raw path.
    let unknown = root
        .attributes()
        .iter()
        .find(|attribute| attribute.name().as_str() == "rootextra")
        .expect("unknown attribute")
        .clone();
    assert_eq!(
        classify_attribute(&list_context, &unknown),
        AttributeDisposition::Reject
    );
}

#[test]
fn expanded_fragment_metadata_requires_the_validated_pair() {
    let root = fixture("rd_extra_attr_v2.rds");
    let class = class_fixture_attribute();
    let context = NodeContext {
        tag: Some(r"\ifelse"),
        value: &RValue::List(Vec::new()),
        attributes: root.attributes(),
    };

    // The fixture supplies class="Rd" but no Rdfile, so class is not
    // accepted on an expanded-fragment tag. The same class is still
    // accepted by the separate LIST policy.
    assert_eq!(
        classify_attribute(&context, &class),
        AttributeDisposition::Reject
    );
    assert!(!valid_expanded_fragment_class(&context, &class));

    // The Rdfile validator requires a validated class attribute, and
    // rejects all invalid character-vector shapes before lowering.
    let rdfile_values = [
        RValue::Character(Vec::new()),
        RValue::Character(vec![RStr::Na, RStr::Na]),
        RValue::Integer(vec![Some(1)]),
    ];
    for value in rdfile_values {
        let candidate = attribute_with_value(&class, value);
        assert!(!valid_expanded_fragment_rdfile(&context, &candidate));
    }

    let details_context = NodeContext {
        tag: Some(r"\details"),
        value: &RValue::List(Vec::new()),
        attributes: root.attributes(),
    };
    assert!(!valid_expanded_fragment_class(&details_context, &class));
    assert!(!valid_expanded_fragment_rdfile(&details_context, &class));
}