use rowan::ast::AstNode as _;
use rowan::{TextRange, TextSize};
use crate::ast::{AssignmentExpr, FunctionExpr, RoxygenBlock, RoxygenSection};
use crate::syntax::{SyntaxElement, SyntaxKind};
const KNOWN_TAGS: &[&str] = &[
"aliases",
"author",
"backref",
"concept",
"describeIn",
"description",
"details",
"docType",
"encoding",
"eval",
"evalNamespace",
"evalRd",
"example",
"examples",
"examplesIf",
"export",
"exportClass",
"exportMethod",
"exportPattern",
"exportS3Method",
"family",
"field",
"format",
"import",
"importClassesFrom",
"importFrom",
"importMethodsFrom",
"include",
"inherit",
"inheritDotParams",
"inheritParams",
"inheritSection",
"keywords",
"md",
"method",
"name",
"noMd",
"noRd",
"note",
"order",
"param",
"rawNamespace",
"rawRd",
"rdname",
"references",
"return",
"returns",
"section",
"seealso",
"slot",
"source",
"template",
"templateVar",
"title",
"usage",
"useDynLib",
];
pub(crate) fn is_known_tag(name: &str) -> bool {
KNOWN_TAGS.binary_search(&name).is_ok()
}
pub(crate) fn inherits_docs(block: &RoxygenBlock) -> bool {
block.tags().any(|tag| {
matches!(
tag.name().as_deref(),
Some(
"inherit"
| "inheritParams"
| "inheritSection"
| "inheritDotParams"
| "rdname"
| "describeIn"
| "template"
| "usage"
)
)
})
}
fn is_namespace_or_toggle_tag(name: &str) -> bool {
matches!(
name,
"export"
| "exportClass"
| "exportMethod"
| "exportPattern"
| "exportS3Method"
| "evalNamespace"
| "import"
| "importClassesFrom"
| "importFrom"
| "importMethodsFrom"
| "include"
| "md"
| "noMd"
| "noRd"
| "rawNamespace"
| "useDynLib"
)
}
pub(crate) fn wants_rd_topic(block: &RoxygenBlock) -> bool {
block.has_tag("export")
|| block.intro().is_some_and(|intro| intro.has_prose())
|| block.tags().any(|tag| {
tag.name()
.is_some_and(|name| !is_namespace_or_toggle_tag(&name))
})
}
pub(crate) fn documented_function(block: &RoxygenBlock) -> Option<FunctionExpr> {
let mut next = block.syntax().next_sibling_or_token();
while let Some(element) = next {
match &element {
SyntaxElement::Token(token) => {
if !matches!(
token.kind(),
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
) {
return None;
}
}
SyntaxElement::Node(node) => {
return match node.kind() {
SyntaxKind::FUNCTION_EXPR => FunctionExpr::cast(node.clone()),
SyntaxKind::ASSIGNMENT_EXPR => {
let assign = AssignmentExpr::cast(node.clone())?;
if !matches!(
assign.op_kind(),
Some(
SyntaxKind::ASSIGN_LEFT
| SyntaxKind::ASSIGN_EQ
| SyntaxKind::SUPER_ASSIGN
)
) || assign.target_name().is_none()
{
return None;
}
match assign.value_element()? {
SyntaxElement::Node(value) => FunctionExpr::cast(value),
SyntaxElement::Token(_) => None,
}
}
_ => None,
};
}
}
next = element.next_sibling_or_token();
}
None
}
pub(crate) enum ParamDoc {
Empty,
Named {
names: Vec<(smol_str::SmolStr, TextRange)>,
has_description: bool,
},
Unknown,
}
pub(crate) fn param_doc(section: &RoxygenSection) -> Option<ParamDoc> {
let tag = section.tag()?;
if tag.name().as_deref() != Some("param") {
return None;
}
if tag.arg().is_some() {
return Some(ParamDoc::Named {
names: tag.arg_names(),
has_description: section.has_prose(),
});
}
if !section.has_prose() {
return Some(ParamDoc::Empty);
}
let tag_end = tag.syntax().text_range().end();
let first_text = section
.syntax()
.descendants_with_tokens()
.filter_map(|e| e.into_token())
.find(|t| t.kind() == SyntaxKind::ROXYGEN_TEXT && t.text_range().start() >= tag_end);
let Some(token) = first_text else {
return Some(ParamDoc::Unknown);
};
let text = token.text();
let Some(word) = text.split_whitespace().next() else {
return Some(ParamDoc::Unknown);
};
let word_offset = text.find(word).expect("first word is in its own text");
let word_start = token.text_range().start() + TextSize::from(word_offset as u32);
let names = split_comma_names(word, word_start);
if names.is_empty() {
return Some(ParamDoc::Unknown);
}
let has_description = !text[word_offset + word.len()..].trim().is_empty()
|| section.paragraphs().count() > 1
|| section
.syntax()
.descendants_with_tokens()
.filter_map(|e| e.into_token())
.any(|t| {
t.kind() == SyntaxKind::ROXYGEN_TEXT
&& t.text_range().start() > token.text_range().end()
});
Some(ParamDoc::Named {
names,
has_description,
})
}
fn split_comma_names(text: &str, start: TextSize) -> Vec<(smol_str::SmolStr, TextRange)> {
let mut names = Vec::new();
let mut offset = 0usize;
for piece in text.split(',') {
let trimmed = piece.trim();
if !trimmed.is_empty() {
let lead = piece.len() - piece.trim_start().len();
let piece_start = start + TextSize::from((offset + lead) as u32);
names.push((
smol_str::SmolStr::new(trimmed),
TextRange::at(piece_start, TextSize::of(trimmed)),
));
}
offset += piece.len() + 1;
}
names
}
struct Segment {
extracted_start: u32,
original_start: u32,
len: u32,
}
#[derive(Default)]
pub(crate) struct ExtractedCode {
pub(crate) code: String,
segments: Vec<Segment>,
}
impl ExtractedCode {
fn push(&mut self, text: &str, original_start: TextSize) {
if text.is_empty() {
return;
}
self.segments.push(Segment {
extracted_start: self.code.len() as u32,
original_start: original_start.into(),
len: text.len() as u32,
});
self.code.push_str(text);
}
pub(crate) fn has_code(&self) -> bool {
!self.code.trim().is_empty()
}
pub(crate) fn map_range(&self, range: TextRange) -> TextRange {
let start = self.map_pos(range.start().into(), false);
let end = self.map_pos(range.end().into(), true).max(start);
TextRange::new(TextSize::from(start), TextSize::from(end))
}
fn map_pos(&self, pos: u32, is_end: bool) -> u32 {
if self.segments.is_empty() {
return 0;
}
let idx = self
.segments
.partition_point(|s| s.extracted_start < pos || (!is_end && s.extracted_start == pos));
let seg = &self.segments[idx.saturating_sub(1)];
let offset = pos.saturating_sub(seg.extracted_start).min(seg.len);
seg.original_start + offset
}
}
pub(crate) struct ExamplesCode {
pub(crate) condition: Option<ExtractedCode>,
pub(crate) body: Option<ExtractedCode>,
}
pub(crate) fn extract_examples(section: &RoxygenSection) -> Option<ExamplesCode> {
let tag = section.tag()?;
if !tag.is_examples() {
return None;
}
let is_conditional = tag.name().as_deref() == Some("examplesIf");
let name_token = tag
.name()
.and_then(|_| tag.at())
.and_then(|at| at.next_sibling_or_token())?;
let mut head_end = name_token.text_range().end();
if let Some(next) = name_token.next_sibling_or_token()
&& next.kind() == SyntaxKind::WHITESPACE
{
head_end = next.text_range().end();
}
let tag_end = tag.syntax().text_range().end();
let mut condition = ExtractedCode::default();
let mut body = ExtractedCode::default();
let mut after_marker = false;
for element in section.syntax().descendants_with_tokens() {
let Some(token) = element.as_token() else {
continue;
};
let range = token.text_range();
if range.end() <= head_end {
continue;
}
let dest = if is_conditional && range.end() <= tag_end {
&mut condition
} else {
&mut body
};
match token.kind() {
SyntaxKind::ROXYGEN_MARKER => {
after_marker = true;
continue;
}
SyntaxKind::WHITESPACE if after_marker => {
dest.push(&token.text()[1..], range.start() + TextSize::from(1));
}
SyntaxKind::NEWLINE => dest.push("\n", range.start()),
_ => dest.push(token.text(), range.start()),
}
after_marker = false;
}
Some(ExamplesCode {
condition: condition.has_code().then_some(condition),
body: body.has_code().then_some(body),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn first_block(src: &str) -> RoxygenBlock {
parse(src)
.cst
.descendants()
.find_map(RoxygenBlock::cast)
.expect("a roxygen block")
}
#[test]
fn known_tags_is_sorted_and_hits() {
assert!(KNOWN_TAGS.is_sorted());
for tag in ["export", "param", "returns", "examplesIf", "noRd"] {
assert!(is_known_tag(tag), "{tag} should be known");
}
assert!(!is_known_tag("exprot"));
assert!(!is_known_tag(""));
}
#[test]
fn documented_function_classifies_shapes() {
let cases: &[(&str, bool)] = &[
("#' T\nf <- function(x) x\n", true),
("#' T\nf = function(x) x\n", true),
("#' T\nf <<- function(x) x\n", true),
("#' T\n\"f\" <- function(x) x\n", true),
("#' T\nfunction(x) x\n", true),
("#' T\n# helper\nf <- function(x) x\n", true),
(
"#' T\nsetMethod(\"show\", \"C\", function(object) 1)\n",
false,
),
("#' T\n\"_PACKAGE\"\n", false),
("#' T\nNULL\n", false),
("#' T\nf <- 1\n", false),
("#' T\ndim(x) <- function() 1\n", false),
("#' T\nfunction(x) x -> f\n", true),
("#' T\n\n#' U\nf <- function(x) x\n", false),
("#' T\n", false),
];
for (src, expect) in cases {
let block = first_block(src);
assert_eq!(
documented_function(&block).is_some(),
*expect,
"case: {src:?}"
);
}
}
#[test]
fn second_block_documents_its_own_function() {
let src = "#' A\nNULL\n\n#' B\ng <- function() 1\n";
let blocks: Vec<_> = parse(src)
.cst
.descendants()
.filter_map(RoxygenBlock::cast)
.collect();
assert_eq!(blocks.len(), 2);
assert!(documented_function(&blocks[0]).is_none());
assert!(documented_function(&blocks[1]).is_some());
}
fn examples_code(src: &str) -> ExamplesCode {
let block = first_block(src);
block
.sections()
.find_map(|s| extract_examples(&s))
.expect("an examples section")
}
#[test]
fn extract_examples_plain_body() {
let src = "#' @examples\n#' g(1)\n#' g(2)\nf <- function() 1\n";
let ex = examples_code(src);
assert!(ex.condition.is_none());
let body = ex.body.expect("body");
assert_eq!(body.code, "\ng(1)\ng(2)");
let pos = body.code.find("g(1)").unwrap() as u32;
let mapped = body.map_range(TextRange::new(TextSize::from(pos), TextSize::from(pos + 4)));
assert_eq!(&src[mapped], "g(1)");
}
#[test]
fn extract_examples_same_line_code_and_indent() {
let src = "#' @examples g(1)\n#' g(2)\nf <- function() 1\n";
let ex = examples_code(src);
let body = ex.body.expect("body");
assert_eq!(body.code, "g(1)\n g(2)");
}
#[test]
fn extract_examples_if_splits_condition_and_body() {
let src = "#' @examplesIf interactive()\n#' g(1)\nf <- function() 1\n";
let ex = examples_code(src);
let condition = ex.condition.expect("condition");
assert_eq!(condition.code, "interactive()");
let body = ex.body.expect("body");
assert_eq!(body.code.trim(), "g(1)");
let mapped = condition.map_range(TextRange::new(
TextSize::from(0),
TextSize::of(&condition.code),
));
assert_eq!(&src[mapped], "interactive()");
}
#[test]
fn extract_examples_md_fragmented_line_is_reassembled() {
let src = "#' @md\n#' @examples\n#' x <- *emph* + 1\nf <- function() 1\n";
let ex = examples_code(src);
let body = ex.body.expect("body");
assert_eq!(body.code, "\nx <- *emph* + 1");
let pos = body.code.find("*emph*").unwrap() as u32;
let mapped = body.map_range(TextRange::new(TextSize::from(pos), TextSize::from(pos + 6)));
assert_eq!(&src[mapped], "*emph*");
}
#[test]
fn extract_examples_blank_comment_lines_keep_line_structure() {
let src = "#' @examples\n#' g(1)\n#'\n#' g(2)\nf <- function() 1\n";
let body = examples_code(src).body.expect("body");
assert_eq!(body.code, "\ng(1)\n\ng(2)");
}
#[test]
fn extract_examples_empty_section_has_no_code() {
let src = "#' @examples\n#'\nf <- function() 1\n";
let ex = examples_code(src);
assert!(ex.condition.is_none());
assert!(ex.body.is_none());
}
#[test]
fn map_range_endpoints_stay_on_their_lines() {
let src = "#' @examples\n#' g(1\n#' h(2)\nf <- function() 1\n";
let body = examples_code(src).body.expect("body");
assert_eq!(body.code, "\ng(1\nh(2)");
let start = body.code.find("g(1").unwrap() as u32;
let end = body.code.find("h(2)").unwrap() as u32 + 4;
let mapped = body.map_range(TextRange::new(TextSize::from(start), TextSize::from(end)));
assert_eq!(&src[mapped], "g(1\n#' h(2)");
}
}