rd-source 0.0.1

Parser for R documentation source files into rd-ast documents
Documentation
use rd_ast::{RdNode, RdPath, RdTag};

fn parse(input: &str) -> rd_ast::RdDocument {
    let parsed = rd_source::parse(input.as_bytes()).unwrap();
    assert!(
        parsed.diagnostics().is_empty(),
        "diagnostics: {:?}",
        parsed.diagnostics()
    );
    parsed.document().clone()
}

fn walk<'a>(nodes: &'a [RdNode], path: RdPath, out: &mut Vec<(&'a RdNode, RdPath)>) {
    for (index, node) in nodes.iter().enumerate() {
        let child_path = path.with_child(index);
        out.push((node, child_path.clone()));
        match node {
            RdNode::Tagged(tagged) => walk(tagged.children(), child_path, out),
            RdNode::Group(group) => walk(group.children(), child_path, out),
            _ => {}
        }
    }
}

fn nodes(document: &rd_ast::RdDocument) -> Vec<(&RdNode, RdPath)> {
    let mut out = Vec::new();
    walk(document.nodes(), RdPath::new(vec![]), &mut out);
    out
}

#[test]
fn producer_inline_mode_matrix() {
    let document = parse(
        r"\description{\emph{x} \code{f(x)} \verb{v} \url{u} \samp{s} \kbd{k} \env{e} \option{o}}",
    );
    let spans: Vec<_> = nodes(&document)
        .into_iter()
        .filter_map(|(node, path)| {
            node.inspect_inline_span(&path)
                .unwrap()
                .map(|view| (view.kind(), view.body().first().map(rd_ast::RdNodeKind::of)))
        })
        .collect();
    assert_eq!(spans.len(), 8);
    assert!(spans.iter().all(|(_, kind)| kind.is_some()));
    assert_eq!(spans[0].1, Some(rd_ast::RdNodeKind::Text));
    assert_eq!(spans[1].1, Some(rd_ast::RdNodeKind::RCode));
    for (_, kind) in &spans[2..] {
        assert_eq!(*kind, Some(rd_ast::RdNodeKind::Verb));
    }
}

#[test]
fn producer_structural_views_round_trip() {
    let document = parse(
        r"\description{\if{html}{content} \ifelse{latex}{a}{b} \enc{São Paulo}{Sao Paulo} \figure{img.png}{options: width=30} \figure{img.png} \linkS4class{myClass} \linkS4class[methods]{myClass}}",
    );
    let mut conditionals = Vec::new();
    let mut enc = Vec::new();
    let mut figures = Vec::new();
    let mut links = Vec::new();
    for (node, path) in nodes(&document) {
        if let Some(view) = node.inspect_conditional(&path).unwrap() {
            conditionals.push(view);
        }
        if let Some(view) = node.inspect_enc(&path).unwrap() {
            enc.push(view);
        }
        if let Some(view) = node.inspect_figure(&path).unwrap() {
            figures.push(view);
        }
        if let Some(view) = node.inspect_s4_class_link(&path).unwrap() {
            links.push(view);
        }
    }
    assert_eq!(conditionals.len(), 2);
    assert_eq!(conditionals[0].format(), "html");
    assert_eq!(conditionals[1].format(), "latex");
    assert_eq!(enc.len(), 1);
    assert_eq!(enc[0].encoded(), &[RdNode::Text("São Paulo".into())]);
    assert_eq!(figures.len(), 2);
    assert_eq!(figures[0].file(), "img.png");
    assert_eq!(
        figures[0].second().unwrap().option_attributes(),
        Some("width=30")
    );
    assert_eq!(figures[1].second(), None);
    assert_eq!(links[0].class_text(), Some("myClass".into()));
    assert_eq!(links[0].package(), None);
    assert_eq!(links[1].package_text(), Some("methods".into()));
}

#[test]
fn producer_method_keeps_call_as_parent_sibling() {
    let document = parse(r"\usage{\method{print}{data.frame}(x, ...)}");
    let mut methods = Vec::new();
    for (node, path) in nodes(&document) {
        if let Some(view) = node.inspect_method(&path).unwrap() {
            methods.push(view);
        }
    }
    assert_eq!(methods.len(), 1);
    assert_eq!(methods[0].generic(), "print");
    assert_eq!(methods[0].qualifier(), "data.frame");
    let usage = document
        .nodes()
        .iter()
        .find_map(|node| match node {
            RdNode::Tagged(tagged) if tagged.tag() == &RdTag::Usage => Some(tagged.children()),
            _ => None,
        })
        .unwrap();
    assert!(
        matches!(usage, [RdNode::Tagged(_), RdNode::RCode(code)] if code.contains("(x, ...)") )
    );
}

#[test]
fn producer_rlike_figure_does_not_absorb_sibling_brace() {
    let document = parse(r"\usage{\figure{f}{o}}");
    let figures: Vec<_> = nodes(&document)
        .into_iter()
        .filter_map(|(node, path)| node.inspect_figure(&path).unwrap())
        .collect();
    assert_eq!(figures.len(), 1);
    assert_eq!(figures[0].file(), "f");
    assert_eq!(figures[0].second(), None);
    let usage = document
        .nodes()
        .iter()
        .find_map(|node| match node {
            RdNode::Tagged(tagged) if tagged.tag() == &RdTag::Usage => Some(tagged.children()),
            _ => None,
        })
        .unwrap();
    assert!(matches!(usage, [RdNode::Tagged(_), RdNode::RCode(code)] if code.contains("{o}")));
}

#[test]
fn producer_zero_argument_symbols_keep_empty_brace_sibling() {
    let document = parse(r"\description{\R{} code \dots{} \ldots{}}");
    let symbols: Vec<_> = nodes(&document)
        .into_iter()
        .filter_map(|(node, path)| node.text_symbol(&path))
        .collect();
    assert_eq!(symbols.len(), 3);
    let description = document
        .nodes()
        .iter()
        .find_map(|node| match node {
            RdNode::Tagged(tagged) if tagged.tag() == &RdTag::Description => {
                Some(tagged.children())
            }
            _ => None,
        })
        .unwrap();
    assert!(matches!(description, [
        RdNode::Tagged(r), RdNode::Tagged(empty_r), RdNode::Text(_),
        RdNode::Tagged(dots), RdNode::Tagged(empty_dots), RdNode::Text(_),
        RdNode::Tagged(ldots), RdNode::Tagged(empty_ldots)
    ] if r.tag() == &RdTag::R && r.children().is_empty()
        && empty_r.tag() == &RdTag::List && empty_r.children().is_empty()
        && dots.tag() == &RdTag::Dots && dots.children().is_empty()
        && empty_dots.tag() == &RdTag::List && empty_dots.children().is_empty()
        && ldots.tag() == &RdTag::LDots && ldots.children().is_empty()
        && empty_ldots.tag() == &RdTag::List && empty_ldots.children().is_empty()));
}

#[test]
fn generation_header_matches_realistic_preamble_shape() {
    let document = parse(
        "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/foo.R\n\\name{x}\\title{t}",
    );
    assert!(matches!(
        document.nodes(),
        [
            RdNode::Comment(marker),
            RdNode::Text(first_newline),
            RdNode::Comment(source),
            RdNode::Text(second_newline),
            RdNode::Tagged(_),
            RdNode::Tagged(_),
        ] if marker == "% Generated by roxygen2: do not edit by hand"
            && first_newline == "\n"
            && source == "% Please edit documentation in R/foo.R"
            && second_newline == "\n"
    ));
    let view = document.generation_header().unwrap();
    assert!(view.is_generated());
    assert_eq!(view.source_files(), &["R/foo.R"]);
}

#[test]
fn generation_header_reads_wrapped_and_crlf_preambles() {
    let unix = parse("% Please edit documentation in R/a.R,\n%   R/b.R\n\\name{x}\\title{t}");
    assert_eq!(
        unix.generation_header().unwrap().source_files(),
        &["R/a.R", "R/b.R"]
    );

    let crlf = parse("% Please edit documentation in R/a.R,\r\n%   R/b.R\r\n\\name{x}\\title{t}");
    assert_eq!(crlf.generation_header(), unix.generation_header());
}

#[test]
fn plain_rd_has_no_generation_header() {
    assert!(parse(r"\name{x}\title{t}").generation_header().is_none());
}

#[test]
fn generation_header_accepts_unknown_generator_and_sources() {
    let document = parse(
        "% Generated by rdocgen: do not edit by hand\n% Please edit documentation in R/foo.R\n\\name{x}\\title{t}",
    );
    let view = document.generation_header().unwrap();
    assert_eq!(
        view.generator(),
        Some(&rd_ast::RdGenerator::Unknown("rdocgen".into()))
    );
    assert_eq!(view.source_files(), &["R/foo.R"]);
}

#[test]
fn lifecycle_badge_view_reads_real_rd() {
    let document = parse(
        r"\name{x}\title{t}\description{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}}}",
    );
    let badges = document.inspect_lifecycle_badges();
    assert_eq!(badges.as_slice().len(), 1);
    let badge = badges.first().unwrap();
    assert_eq!(badge.stage(), &rd_ast::RdLifecycleStage::Stable);
    assert_eq!(badge.figure().file(), "lifecycle-stable.svg");
    assert_eq!(badge.canonical_shape().unwrap().fallback_text(), "Stable");
    assert!(badges.diagnostics().is_empty());

    let bare = parse(r"\name{x}\title{t}\description{\figure{lifecycle-stable.svg}}");
    let bare_badges = bare.lifecycle_badges();
    let badge = bare_badges.first().unwrap();
    assert!(badge.canonical_shape().is_none());
}

#[test]
fn producer_system_macro_view_uses_curated_tags() {
    let document = parse(r"\description{\doi{10.1/x} \CRANpkg{stats} \sspace \I{abc}}");
    let description = document
        .nodes()
        .iter()
        .find_map(|node| match node {
            RdNode::Tagged(tagged) if tagged.tag() == &RdTag::Description => {
                Some(tagged.children())
            }
            _ => None,
        })
        .unwrap();
    let items: Vec<_> = rd_ast::RdSystemMacroItems::children(
        description,
        &RdPath::new(vec![rd_ast::RdPathSegment::TopLevel(0)]),
    )
    .collect();
    let macros: Vec<_> = items
        .iter()
        .filter_map(|item| match item {
            rd_ast::RdSystemMacroItem::Macro(m) => Some(m),
            _ => None,
        })
        .collect();
    assert_eq!(macros.len(), 4);
    assert!(matches!(
        macros[0].semantic(),
        rd_ast::RdSystemMacro::Doi { id: "10.1/x" }
    ));
    assert!(matches!(
        macros[1].semantic(),
        rd_ast::RdSystemMacro::CranPkg { package: "stats" }
    ));
    assert!(matches!(
        macros[2].semantic(),
        rd_ast::RdSystemMacro::Sspace
    ));
    assert!(
        matches!(macros[3].semantic(), rd_ast::RdSystemMacro::I { body } if body == [RdNode::Text("abc".into())])
    );
    assert!(
        macros
            .iter()
            .all(|m| m.origin() == rd_ast::RdSystemMacroOrigin::CuratedTag && m.consumed() == 1)
    );
}