rd-ast 0.0.1

Canonical, producer-agnostic AST for R's Rd (documentation) format
Documentation
use crate::{RdDocument, RdNode, RdTag, producer};

const GENERATED: &str = "% Generated by roxygen2: do not edit by hand";

fn document(nodes: Vec<RdNode>) -> RdDocument {
    RdDocument::new(nodes)
}

fn comment(text: &str) -> RdNode {
    RdNode::Comment(text.into())
}

fn text(text: &str) -> RdNode {
    RdNode::Text(text.into())
}

#[test]
fn recognizes_marker_and_sources() {
    let doc = document(vec![
        comment(GENERATED),
        comment("% Please edit documentation in R/foo.R"),
    ]);
    let view = doc.generation_header().unwrap();
    assert!(view.is_generated());
    assert_eq!(view.generator(), Some(&crate::RdGenerator::Roxygen2));
    assert_eq!(view.source_files(), &["R/foo.R"]);
}

#[test]
fn preserves_source_order_and_wrapped_files() {
    let doc = document(vec![
        comment("% Please edit documentation in R/a.R, R/a.R, ./weird.R,, R/b.R"),
        comment("%   R/c.R, R/d.R"),
    ]);
    assert_eq!(
        doc.generation_header().unwrap().source_files(),
        &["R/a.R", "R/a.R", "./weird.R", "R/b.R", "R/c.R", "R/d.R"]
    );
}

#[test]
fn marker_and_source_presence_are_independent() {
    let marker_document = document(vec![comment(GENERATED)]);
    let marker = marker_document.generation_header().unwrap();
    assert!(marker.is_generated());
    assert!(!marker.has_sources());
    assert!(marker.source_files().is_empty());

    let source_document = document(vec![comment("% Please edit documentation in R/foo.R")]);
    let source = source_document.generation_header().unwrap();
    assert_eq!(source.generator(), None);
    assert!(!source.is_generated());
    assert!(source.has_sources());
}

#[test]
fn scans_only_the_leading_region_and_ignores_nested_comments() {
    assert!(
        document(vec![comment("% ordinary comment")])
            .generation_header()
            .is_none()
    );
    assert!(document(vec![]).generation_header().is_none());
    assert!(
        document(vec![text("not whitespace"), comment(GENERATED)])
            .generation_header()
            .is_none()
    );
    assert!(
        document(vec![RdNode::tagged(
            RdTag::Title,
            None,
            vec![comment(GENERATED)],
        )])
        .generation_header()
        .is_none()
    );
    assert!(
        document(vec![RdNode::group(vec![comment(GENERATED)])])
            .generation_header()
            .is_none()
    );
    let raw = RdNode::Raw(producer::raw_node(
        Some("opaque".into()),
        None,
        vec![],
        None,
        vec![],
    ));
    assert!(
        document(vec![raw, comment(GENERATED)])
            .generation_header()
            .is_none()
    );
}

#[test]
fn permits_whitespace_and_unrelated_comments_but_resets_continuations() {
    let doc = document(vec![
        comment(GENERATED),
        text("\n  \t"),
        comment("% unrelated"),
        text("\n"),
        comment("% Please edit documentation in R/a.R"),
        comment("% ordinary comment"),
        comment("%   R/not-a-continuation.R"),
    ]);
    let view = doc.generation_header().unwrap();
    assert!(view.is_generated());
    assert_eq!(view.source_files(), &["R/a.R"]);
}

#[test]
fn generated_marker_ends_an_active_source_block() {
    let doc = document(vec![
        comment("% Please edit documentation in R/a.R,"),
        comment(GENERATED),
        comment("%   R/not-a-continuation.R"),
    ]);
    let view = doc.generation_header().unwrap();
    assert_eq!(view.generator(), Some(&crate::RdGenerator::Roxygen2));
    assert_eq!(view.source_files(), &["R/a.R"]);
}

#[test]
fn stops_after_non_header_content_and_rejects_near_misses() {
    let unknown = document(vec![comment(
        "% Generated by ROXYGEN2: do not edit by hand",
    )]);
    assert_eq!(
        unknown.generation_header().unwrap().generator(),
        Some(&crate::RdGenerator::Unknown("ROXYGEN2".into()))
    );

    for near_miss in [
        "% contains Generated by roxygen2",
        "% Generated by roxygen2: DO NOT EDIT",
        "% Generated by : do not edit by hand",
        "% Generated by a:b: do not edit by hand",
        "% Generated by roxygen2: do not edit by hand extra",
        "% Generated by roxygen2: do not edit by hand\r",
    ] {
        assert!(
            document(vec![comment(near_miss)])
                .generation_header()
                .is_none()
        );
    }

    let partial = document(vec![comment(GENERATED), text("body")]);
    assert!(partial.generation_header().unwrap().is_generated());
}

#[test]
fn recognizes_unknown_and_versioned_generators() {
    for (marker, expected) in [
        ("% Generated by rdocgen: do not edit by hand", "rdocgen"),
        (
            "% Generated by roxygen2 (development version): do not edit by hand",
            "roxygen2 (development version)",
        ),
    ] {
        let doc = document(vec![comment(marker)]);
        let view = doc.generation_header().unwrap();
        assert_eq!(
            view.generator(),
            Some(&crate::RdGenerator::Unknown(expected.into()))
        );
        assert!(view.is_generated());
    }
}

#[test]
fn multiple_markers_keep_first_generator_and_reset_sources() {
    let doc = document(vec![
        comment("% Please edit documentation in R/a.R,"),
        comment(GENERATED),
        comment("%   R/not-a-continuation.R"),
        comment("% Generated by rdocgen: do not edit by hand"),
        comment("%   R/still-not-a-continuation.R"),
    ]);
    let view = doc.generation_header().unwrap();
    assert_eq!(view.generator(), Some(&crate::RdGenerator::Roxygen2));
    assert_eq!(view.source_files(), &["R/a.R"]);
}