use std::collections::BTreeMap;
use rowan::ast::AstNode as _;
use rowan::{TextRange, TextSize};
use smol_str::SmolStr;
use crate::ast::{AssignmentExpr, FunctionExpr, RoxygenBlock, RoxygenSection};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, salsa::SalsaValue)]
pub struct TopicMember {
pub has_title: bool,
pub has_value: bool,
pub inherits_params: bool,
pub joins: bool,
pub formals: Option<Vec<String>>,
pub documented_params: Vec<String>,
}
pub fn file_roxygen_topics(root: &SyntaxNode) -> BTreeMap<String, Vec<TopicMember>> {
let mut topics: BTreeMap<String, Vec<TopicMember>> = BTreeMap::new();
for block in root.descendants().filter_map(RoxygenBlock::cast) {
if let Some(key) = topic_key(&block) {
topics
.entry(key.to_string())
.or_default()
.push(topic_member(&block));
}
}
topics
}
pub fn topic_member(block: &RoxygenBlock) -> TopicMember {
let mut documented_params = Vec::new();
for section in block.sections() {
if let Some(ParamDoc::Named { names, .. }) = param_doc(§ion) {
documented_params.extend(names.into_iter().map(|(name, _)| name.to_string()));
}
}
TopicMember {
has_title: has_title(block),
has_value: block.has_tag("return") || block.has_tag("returns"),
inherits_params: inherits_params(block),
joins: joins_other_topic(block),
formals: documented_function(block)
.map(|f| f.params().into_iter().map(|p| p.name.to_string()).collect()),
documented_params,
}
}
pub fn inherits_params(block: &RoxygenBlock) -> bool {
block.tags().any(|tag| {
matches!(
tag.name().as_deref(),
Some(
"inherit"
| "inheritParams"
| "inheritSection"
| "inheritDotParams"
| "template"
| "usage"
)
)
})
}
pub fn joins_other_topic(block: &RoxygenBlock) -> bool {
block.has_tag("rdname") || block.has_tag("describeIn")
}
pub fn has_title(block: &RoxygenBlock) -> bool {
block.intro().is_some_and(|intro| intro.has_prose()) || block.has_tag("title")
}
pub fn topic_key(block: &RoxygenBlock) -> Option<SmolStr> {
let mut rdname: Option<SmolStr> = None;
let mut describe_in: Option<SmolStr> = None;
for tag in block.tags() {
match tag.name().as_deref() {
Some("name") => {
if let Some(value) = tag.value_text() {
return Some(SmolStr::new(value));
}
}
Some("rdname") if rdname.is_none() => rdname = tag.value_text().map(SmolStr::new),
Some("describeIn") if describe_in.is_none() => {
describe_in = tag
.value_text()
.and_then(|value| value.split_whitespace().next().map(SmolStr::new));
}
_ => {}
}
}
rdname
.or(describe_in)
.or_else(|| documented_binding_name(block))
}
fn documented_statement(block: &RoxygenBlock) -> Option<SyntaxNode> {
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 Some(node.clone()),
}
next = element.next_sibling_or_token();
}
None
}
fn documented_assignment(block: &RoxygenBlock) -> Option<AssignmentExpr> {
let node = documented_statement(block)?;
if node.kind() != SyntaxKind::ASSIGNMENT_EXPR {
return None;
}
let assign = AssignmentExpr::cast(node)?;
if !matches!(
assign.op_kind(),
Some(SyntaxKind::ASSIGN_LEFT | SyntaxKind::ASSIGN_EQ | SyntaxKind::SUPER_ASSIGN)
) || assign.target_name().is_none()
{
return None;
}
Some(assign)
}
pub fn documented_binding_name(block: &RoxygenBlock) -> Option<SmolStr> {
documented_assignment(block)?.target_name()
}
pub fn documented_function(block: &RoxygenBlock) -> Option<FunctionExpr> {
let node = documented_statement(block)?;
match node.kind() {
SyntaxKind::FUNCTION_EXPR => FunctionExpr::cast(node),
SyntaxKind::ASSIGNMENT_EXPR => match documented_assignment(block)?.value_element()? {
SyntaxElement::Node(value) => FunctionExpr::cast(value),
SyntaxElement::Token(_) => None,
},
_ => None,
}
}
pub enum ParamDoc {
Empty,
Named {
names: Vec<(SmolStr, TextRange)>,
has_description: bool,
},
Unknown,
}
pub 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<(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((
SmolStr::new(trimmed),
TextRange::at(piece_start, TextSize::of(trimmed)),
));
}
offset += piece.len() + 1;
}
names
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn topics(src: &str) -> BTreeMap<String, Vec<TopicMember>> {
file_roxygen_topics(&parse(src).cst)
}
fn only(src: &str, key: &str) -> TopicMember {
topics(src)
.remove(key)
.and_then(|mut m| (m.len() == 1).then(|| m.remove(0)))
.unwrap_or_else(|| panic!("exactly one member under `{key}` in {src:?}"))
}
#[test]
fn groups_an_owner_with_its_joiners() {
let src = "#' Owner\n#' @param x X.\nf <- function() NULL\n\n\
#' @rdname f\ng <- function(x) x\n\n\
#' Unrelated\nh <- function() 1\n";
let topics = topics(src);
assert_eq!(topics["f"].len(), 2);
assert_eq!(topics["h"].len(), 1);
assert!(!topics.contains_key("nope"));
assert!(!topics["f"][0].joins);
assert!(topics["f"][1].joins);
}
#[test]
fn records_the_facts_a_topic_is_judged_by() {
let member = only(
"#' Add\n#' @param x X.\n#' @return A number.\nf <- function(x, y) x\n",
"f",
);
assert!(member.has_title);
assert!(member.has_value);
assert!(!member.inherits_params);
assert!(!member.joins);
assert_eq!(member.formals, Some(vec!["x".to_string(), "y".to_string()]));
assert_eq!(member.documented_params, ["x"]);
}
#[test]
fn returns_is_an_alias_for_return() {
assert!(only("#' Add\n#' @returns A number.\nf <- function() 1\n", "f").has_value);
}
#[test]
fn an_unclassifiable_statement_has_unknowable_formals() {
let member = only(
"#' Show\n#' @name show_c\nsetMethod(\"show\", \"C\", function(object) 1)\n",
"show_c",
);
assert_eq!(member.formals, None);
}
#[test]
fn topic_key_follows_roxygen2_precedence() {
let cases: &[(&str, Option<&str>)] = &[
("#' T\nf <- function(x) x\n", Some("f")),
("#' T\n\"f\" <- function(x) x\n", Some("f")),
("#' @name a\n#' @rdname b\nf <- function() 1\n", Some("a")),
("#' @rdname b\nf <- function() 1\n", Some("b")),
(
"#' @describeIn b Some variant.\nf <- function() 1\n",
Some("b"),
),
("#' @name\nf <- function() 1\n", Some("f")),
(
"#' T\nsetMethod(\"show\", \"C\", function(object) 1)\n",
None,
),
];
for (src, expect) in cases {
let keys: Vec<String> = topics(src).into_keys().collect();
match expect {
Some(key) => assert_eq!(keys, [key.to_string()], "case: {src:?}"),
None => assert!(keys.is_empty(), "case: {src:?} -> {keys:?}"),
}
}
}
#[test]
fn a_markdown_topic_name_is_not_truncated() {
let src = "#' @md\n#' Missing argument\nmissing_arg <- function() NULL\n\n\
#' @md\n#' @rdname missing_arg\nis_missing <- function(x) TRUE\n";
assert_eq!(topics(src)["missing_arg"].len(), 2);
}
#[test]
fn is_range_free_across_a_body_edit() {
let before = "#' Add\n#' @param x X.\nf <- function(x) {\n x\n}\n";
let after = "#' Add\n#' @param x X.\nf <- function(x) {\n x + 0\n}\n";
assert_eq!(topics(before), topics(after));
}
}