Skip to main content

rd_ast/view/
generation.rs

1use crate::{RdDocument, RdNode};
2
3const SOURCE_FILES_PREFIX: &str = "% Please edit documentation in ";
4const SOURCE_FILES_CONTINUATION_PREFIX: &str = "%   ";
5const GENERATED_PREFIX: &str = "% Generated by ";
6const GENERATED_SUFFIX: &str = ": do not edit by hand";
7
8/// A lossy view of generation header comments at the start of a document.
9///
10/// Source paths borrow the original comment text and retain their spelling,
11/// order, and duplicates. They are not normalized, deduplicated, or checked
12/// for existence. This deliberately differs from the legacy rd2qmd scanner,
13/// which used contains-based matching and looser continuation rules: roxygen2
14/// emits a fixed marker and wraps multi-file lists with `%   `. The source-file
15/// lines and continuation rules remain a roxygen-specific convention this
16/// view understands.
17///
18/// Near-miss header text is simply not recognized. There is deliberately no
19/// `inspect_*` counterpart because no structural error exists for this view.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RdGenerationHeader<'a> {
22    generator: Option<RdGenerator>,
23    source_files: Vec<&'a str>,
24}
25
26/// The generator that produced a documentation header.
27#[non_exhaustive]
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub enum RdGenerator {
30    Roxygen2,
31    Unknown(String),
32}
33
34impl<'a> RdGenerationHeader<'a> {
35    pub fn generator(&self) -> Option<&RdGenerator> {
36        self.generator.as_ref()
37    }
38
39    pub fn is_generated(&self) -> bool {
40        self.generator().is_some()
41    }
42
43    pub fn source_files(&self) -> &[&'a str] {
44        &self.source_files
45    }
46
47    pub fn has_sources(&self) -> bool {
48        !self.source_files.is_empty()
49    }
50}
51
52impl RdDocument {
53    /// Returns the recognized generation header in the leading top-level region.
54    ///
55    /// The view scans only leading comments interleaved with whitespace text.
56    /// It is lossy: near-miss text is not recognized, and there is deliberately
57    /// no inspection variant because this view has no structural error state.
58    pub fn generation_header(&self) -> Option<RdGenerationHeader<'_>> {
59        let mut generator = None;
60        let mut source_files = Vec::new();
61        let mut source_block_active = false;
62        let mut recognized = false;
63
64        for node in self.nodes() {
65            let comment = match node {
66                RdNode::Comment(comment) => comment.as_str(),
67                RdNode::Text(text) if text.chars().all(char::is_whitespace) => continue,
68                _ => break,
69            };
70
71            if comment.contains('\r') {
72                source_block_active = false;
73            } else if let Some(found_generator) = parse_generated_marker(comment) {
74                if generator.is_none() {
75                    generator = Some(found_generator);
76                }
77                recognized = true;
78                source_block_active = false;
79            } else if let Some(payload) = comment.strip_prefix(SOURCE_FILES_PREFIX) {
80                recognized = true;
81                source_block_active = true;
82                append_sources(payload, &mut source_files);
83            } else if source_block_active {
84                if let Some(payload) = comment.strip_prefix(SOURCE_FILES_CONTINUATION_PREFIX) {
85                    recognized = true;
86                    append_sources(payload, &mut source_files);
87                } else {
88                    source_block_active = false;
89                }
90            }
91        }
92
93        recognized.then_some(RdGenerationHeader {
94            generator,
95            source_files,
96        })
97    }
98}
99
100fn parse_generated_marker(comment: &str) -> Option<RdGenerator> {
101    let name = comment
102        .strip_prefix(GENERATED_PREFIX)?
103        .strip_suffix(GENERATED_SUFFIX)?;
104    if name.is_empty() || name.chars().any(|ch| matches!(ch, ':' | '\r' | '\n')) {
105        return None;
106    }
107    Some(if name == "roxygen2" {
108        RdGenerator::Roxygen2
109    } else {
110        RdGenerator::Unknown(name.to_owned())
111    })
112}
113
114fn append_sources<'a>(payload: &'a str, source_files: &mut Vec<&'a str>) {
115    source_files.extend(
116        payload
117            .split(',')
118            .map(str::trim)
119            .filter(|path| !path.is_empty()),
120    );
121}