rd-ast 0.0.1

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

const SOURCE_FILES_PREFIX: &str = "% Please edit documentation in ";
const SOURCE_FILES_CONTINUATION_PREFIX: &str = "%   ";
const GENERATED_PREFIX: &str = "% Generated by ";
const GENERATED_SUFFIX: &str = ": do not edit by hand";

/// A lossy view of generation header comments at the start of a document.
///
/// Source paths borrow the original comment text and retain their spelling,
/// order, and duplicates. They are not normalized, deduplicated, or checked
/// for existence. This deliberately differs from the legacy rd2qmd scanner,
/// which used contains-based matching and looser continuation rules: roxygen2
/// emits a fixed marker and wraps multi-file lists with `%   `. The source-file
/// lines and continuation rules remain a roxygen-specific convention this
/// view understands.
///
/// Near-miss header text is simply not recognized. There is deliberately no
/// `inspect_*` counterpart because no structural error exists for this view.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RdGenerationHeader<'a> {
    generator: Option<RdGenerator>,
    source_files: Vec<&'a str>,
}

/// The generator that produced a documentation header.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RdGenerator {
    Roxygen2,
    Unknown(String),
}

impl<'a> RdGenerationHeader<'a> {
    pub fn generator(&self) -> Option<&RdGenerator> {
        self.generator.as_ref()
    }

    pub fn is_generated(&self) -> bool {
        self.generator().is_some()
    }

    pub fn source_files(&self) -> &[&'a str] {
        &self.source_files
    }

    pub fn has_sources(&self) -> bool {
        !self.source_files.is_empty()
    }
}

impl RdDocument {
    /// Returns the recognized generation header in the leading top-level region.
    ///
    /// The view scans only leading comments interleaved with whitespace text.
    /// It is lossy: near-miss text is not recognized, and there is deliberately
    /// no inspection variant because this view has no structural error state.
    pub fn generation_header(&self) -> Option<RdGenerationHeader<'_>> {
        let mut generator = None;
        let mut source_files = Vec::new();
        let mut source_block_active = false;
        let mut recognized = false;

        for node in self.nodes() {
            let comment = match node {
                RdNode::Comment(comment) => comment.as_str(),
                RdNode::Text(text) if text.chars().all(char::is_whitespace) => continue,
                _ => break,
            };

            if comment.contains('\r') {
                source_block_active = false;
            } else if let Some(found_generator) = parse_generated_marker(comment) {
                if generator.is_none() {
                    generator = Some(found_generator);
                }
                recognized = true;
                source_block_active = false;
            } else if let Some(payload) = comment.strip_prefix(SOURCE_FILES_PREFIX) {
                recognized = true;
                source_block_active = true;
                append_sources(payload, &mut source_files);
            } else if source_block_active {
                if let Some(payload) = comment.strip_prefix(SOURCE_FILES_CONTINUATION_PREFIX) {
                    recognized = true;
                    append_sources(payload, &mut source_files);
                } else {
                    source_block_active = false;
                }
            }
        }

        recognized.then_some(RdGenerationHeader {
            generator,
            source_files,
        })
    }
}

fn parse_generated_marker(comment: &str) -> Option<RdGenerator> {
    let name = comment
        .strip_prefix(GENERATED_PREFIX)?
        .strip_suffix(GENERATED_SUFFIX)?;
    if name.is_empty() || name.chars().any(|ch| matches!(ch, ':' | '\r' | '\n')) {
        return None;
    }
    Some(if name == "roxygen2" {
        RdGenerator::Roxygen2
    } else {
        RdGenerator::Unknown(name.to_owned())
    })
}

fn append_sources<'a>(payload: &'a str, source_files: &mut Vec<&'a str>) {
    source_files.extend(
        payload
            .split(',')
            .map(str::trim)
            .filter(|path| !path.is_empty()),
    );
}