use std::collections::BTreeSet;
use rowan::ast::AstNode as _;
use crate::ast::{CallExpr, RoxygenBlock};
use crate::semantic::SemanticModel;
use crate::syntax::{SyntaxKind, SyntaxNode};
const PACKAGE_TAGS: [(&str, Words); 6] = [
("import", Words::All),
("importFrom", Words::First),
("importClassesFrom", Words::First),
("importMethodsFrom", Words::First),
("rawNamespace", Words::All),
("depends", Words::All),
];
#[derive(Clone, Copy, PartialEq, Eq)]
enum Words {
First,
All,
}
const METHODS_CALLS: [&str; 5] = [
"setClass",
"setMethod",
"setGeneric",
"setRefClass",
"setValidity",
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PackageReferences {
pub direct: BTreeSet<String>,
pub roxygen_imports: BTreeSet<String>,
pub string_mentions: BTreeSet<String>,
pub uses_methods: bool,
}
pub fn file_package_references(root: &SyntaxNode, model: &SemanticModel) -> PackageReferences {
let mut refs = PackageReferences {
direct: model
.referenced_packages()
.iter()
.map(|pkg| pkg.to_string())
.chain(
model
.loaded_packages()
.iter()
.map(|pkg| pkg.name.to_string()),
)
.collect(),
..Default::default()
};
for node in root.descendants() {
match node.kind() {
SyntaxKind::ROXYGEN_BLOCK => {
let Some(block) = RoxygenBlock::cast(node.clone()) else {
continue;
};
collect_roxygen_imports(&block, &mut refs.roxygen_imports);
}
SyntaxKind::CALL_EXPR => {
if let Some(call) = CallExpr::cast(node.clone())
&& call
.callee_name()
.is_some_and(|name| METHODS_CALLS.contains(&name.as_str()))
{
refs.uses_methods = true;
}
}
_ => {}
}
}
for token in root
.descendants_with_tokens()
.filter_map(|el| el.into_token())
.filter(|token| token.kind() == SyntaxKind::STRING)
{
let text = token.text();
let inner = text
.strip_prefix(['"', '\''])
.and_then(|rest| rest.get(..rest.len().saturating_sub(1)))
.unwrap_or(text);
if is_package_shaped(inner) {
refs.string_mentions.insert(inner.to_string());
}
}
refs
}
fn collect_roxygen_imports(block: &RoxygenBlock, out: &mut BTreeSet<String>) {
for section in block.sections() {
let Some(tag) = section.tag() else {
continue;
};
let Some(name) = tag.name() else {
continue;
};
let Some((_, words)) = PACKAGE_TAGS
.iter()
.find(|(known, _)| *known == name.as_str())
else {
continue;
};
let arg = tag.arg().map(|t| t.text().to_string()).unwrap_or_default();
let text = tag.text().map(|t| t.text().to_string()).unwrap_or_default();
let joined = format!("{arg} {text}");
let mut candidates = joined
.split(|c: char| c.is_whitespace() || matches!(c, ',' | '(' | ')' | '"' | '\''))
.filter(|word| is_package_shaped(word));
match words {
Words::First => out.extend(candidates.next().map(str::to_string)),
Words::All => out.extend(candidates.map(str::to_string)),
}
}
}
fn is_package_shaped(name: &str) -> bool {
name.len() >= 2
&& name.starts_with(|c: char| c.is_ascii_alphabetic())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.')
}
#[cfg(test)]
mod tests {
use super::*;
fn refs(source: &str) -> PackageReferences {
let parsed = crate::parser::parse(source);
let model = SemanticModel::build(&parsed.cst);
file_package_references(&parsed.cst, &model)
}
#[test]
fn qualified_access_is_direct() {
assert!(refs("dplyr::filter(x)\n").direct.contains("dplyr"));
assert!(refs("dplyr:::internal(x)\n").direct.contains("dplyr"));
}
#[test]
fn attaching_is_direct() {
assert!(refs("library(dplyr)\n").direct.contains("dplyr"));
assert!(refs("require(dplyr)\n").direct.contains("dplyr"));
}
#[test]
fn a_load_inside_a_function_body_is_direct() {
let source = "f <- function() {\n requireNamespace(\"dplyr\")\n}\n";
assert!(refs(source).direct.contains("dplyr"));
assert!(
refs("f <- function() loadNamespace(\"dplyr\")\n")
.direct
.contains("dplyr")
);
}
#[test]
fn roxygen_import_tags_name_packages() {
let source = "#' @importFrom dplyr filter select\nf <- function() 1\n";
let found = refs(source);
assert!(found.roxygen_imports.contains("dplyr"));
assert!(!found.roxygen_imports.contains("filter"));
}
#[test]
fn roxygen_import_names_every_package() {
let source = "#' @import dplyr rlang\nf <- function() 1\n";
let found = refs(source);
assert!(found.roxygen_imports.contains("dplyr"));
assert!(found.roxygen_imports.contains("rlang"));
}
#[test]
fn s4_definitions_use_methods() {
assert!(refs("setClass(\"A\", representation(x = \"numeric\"))\n").uses_methods);
assert!(refs("setMethod(\"show\", \"A\", function(object) NULL)\n").uses_methods);
assert!(!refs("f <- function() 1\n").uses_methods);
}
#[test]
fn package_shaped_strings_are_mentions_not_references() {
let found = refs("do.call(\"::\", list(\"dplyr\", \"filter\"))\n");
assert!(found.string_mentions.contains("dplyr"));
assert!(!found.direct.contains("dplyr"));
}
#[test]
fn prose_is_not_a_package_mention() {
let found = refs("message(\"could not find the file\")\n");
assert!(found.string_mentions.is_empty(), "{found:?}");
}
#[test]
fn a_package_argument_is_a_mention() {
let found = refs("system.file(\"extdata\", package = \"dplyr\")\n");
assert!(found.string_mentions.contains("dplyr"));
}
}