use std::path::{Path, PathBuf};
use rowan::NodeOrToken;
use rowan::TextRange;
use rowan::ast::AstNode as _;
use crate::ast::CallExpr;
use crate::syntax::{RLanguage, SyntaxKind, SyntaxNode};
type SyntaxToken = rowan::SyntaxToken<RLanguage>;
type SyntaxElement = NodeOrToken<SyntaxNode, SyntaxToken>;
#[derive(Debug, Clone, PartialEq, Eq, Hash, salsa::Update)]
pub enum SourceTarget {
Path(PathBuf),
Dynamic,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, salsa::Update)]
pub struct SourceEdgeKey {
pub target: SourceTarget,
pub local: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceEdge {
pub target: SourceTarget,
pub local: bool,
pub range: TextRange,
}
impl SourceEdge {
pub fn contributes_scope(&self) -> bool {
!self.local && matches!(self.target, SourceTarget::Path(_))
}
pub fn key(&self) -> SourceEdgeKey {
SourceEdgeKey {
target: self.target.clone(),
local: self.local,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, salsa::Update)]
pub enum TopLevelEvent {
Define(String),
SourceEdge(SourceEdgeKey),
Read(String),
}
pub fn top_level_source_edge_key(
child: &SyntaxNode,
base_dir: Option<&Path>,
) -> Option<SourceEdgeKey> {
let call = CallExpr::cast(child.clone())?;
let callee = call.callee_token()?;
if callee.kind() != SyntaxKind::IDENT {
return None;
}
match callee.text() {
"source" => Some(source_edge(&call, base_dir).key()),
"sys.source" => Some(SourceEdgeKey {
target: SourceTarget::Dynamic,
local: false,
}),
_ => None,
}
}
pub fn collect_source_edges(root: &SyntaxNode, base_dir: Option<&Path>) -> Vec<SourceEdge> {
root.children()
.filter_map(|child| source_call(&child))
.map(|call| source_edge(&call, base_dir))
.collect()
}
pub fn collect_source_edge_keys(root: &SyntaxNode, base_dir: Option<&Path>) -> Vec<SourceEdgeKey> {
root.children()
.filter_map(|child| source_call(&child))
.map(|call| source_edge(&call, base_dir).key())
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceLiteralEdge {
pub target: PathBuf,
pub literal_range: TextRange,
pub quote: u8,
pub spelling: String,
pub was_relative: bool,
}
pub fn collect_source_literal_edges(
root: &SyntaxNode,
base_dir: Option<&Path>,
) -> Vec<SourceLiteralEdge> {
root.children()
.filter_map(|child| source_call(&child))
.filter_map(|call| source_literal_edge(&call, base_dir))
.collect()
}
fn is_relative_spelling(spelling: &str) -> bool {
let bytes = spelling.as_bytes();
match bytes {
[b'/' | b'\\', ..] => false,
[drive, b':', ..] if drive.is_ascii_alphabetic() => false,
_ => true,
}
}
fn source_literal_edge(call: &CallExpr, base_dir: Option<&Path>) -> Option<SourceLiteralEdge> {
let (file_value, _local) = source_file_value(call);
let NodeOrToken::Token(token) = file_value? else {
return None;
};
if token.kind() != SyntaxKind::STRING {
return None;
}
let spelling = strip_quotes(token.text())?.to_string();
let quote = token.text().as_bytes()[0];
let path = PathBuf::from(&spelling);
let was_relative = is_relative_spelling(&spelling);
let target = match base_dir {
Some(dir) if was_relative => dir.join(&path),
_ => path,
};
Some(SourceLiteralEdge {
target,
literal_range: token.text_range(),
quote,
spelling,
was_relative,
})
}
pub fn relative_path(base_dir: &Path, target: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut base = base_dir.components().peekable();
let mut targ = target.components().peekable();
while let (Some(b), Some(t)) = (base.peek(), targ.peek()) {
if b == t {
base.next();
targ.next();
} else {
break;
}
}
let mut result = PathBuf::new();
for comp in base {
match comp {
Component::Normal(_) => result.push(".."),
Component::CurDir => {}
Component::RootDir | Component::Prefix(_) | Component::ParentDir => return None,
}
}
for comp in targ {
result.push(comp.as_os_str());
}
Some(result)
}
fn source_call(node: &SyntaxNode) -> Option<CallExpr> {
let call = CallExpr::cast(node.clone())?;
let callee = call.callee_token()?;
(callee.kind() == SyntaxKind::IDENT && callee.text() == "source").then_some(call)
}
fn source_file_value(call: &CallExpr) -> (Option<SyntaxElement>, bool) {
let mut file_value: Option<SyntaxElement> = None;
let mut local = false;
let mut seen_positional = false;
if let Some(arg_list) = call.arg_list() {
for arg in arg_list.args() {
let (name, value) = arg_parts(arg.syntax());
match name.as_deref() {
Some("file") => file_value = file_value.or(value),
Some("local") => local = value.as_ref().is_some_and(is_true_literal),
Some(_) => {}
None => {
if !seen_positional {
file_value = file_value.or(value);
seen_positional = true;
}
}
}
}
}
(file_value, local)
}
fn source_edge(call: &CallExpr, base_dir: Option<&Path>) -> SourceEdge {
let (file_value, local) = source_file_value(call);
let target = match file_value {
Some(value) => target_from_value(&value, base_dir),
None => SourceTarget::Dynamic,
};
SourceEdge {
target,
local,
range: call.syntax().text_range(),
}
}
fn arg_parts(arg: &SyntaxNode) -> (Option<String>, Option<SyntaxElement>) {
let elements: Vec<SyntaxElement> = arg.children_with_tokens().collect();
match elements
.iter()
.position(|e| e.kind() == SyntaxKind::ASSIGN_EQ)
{
Some(eq) => {
let name = elements[..eq].iter().rev().find_map(token_name);
let value = elements[eq + 1..]
.iter()
.find(|e| !is_trivia(e.kind()))
.cloned();
(name, value)
}
None => {
let value = elements.iter().find(|e| !is_trivia(e.kind())).cloned();
(None, value)
}
}
}
fn target_from_value(value: &SyntaxElement, base_dir: Option<&Path>) -> SourceTarget {
if let NodeOrToken::Token(token) = value
&& token.kind() == SyntaxKind::STRING
&& let Some(literal) = strip_quotes(token.text())
{
let path = PathBuf::from(literal);
let resolved = match base_dir {
Some(dir) if path.is_relative() => dir.join(path),
_ => path,
};
return SourceTarget::Path(resolved);
}
SourceTarget::Dynamic
}
fn is_true_literal(value: &SyntaxElement) -> bool {
matches!(value, NodeOrToken::Token(t)
if t.kind() == SyntaxKind::IDENT && matches!(t.text(), "TRUE" | "T"))
}
fn token_name(element: &SyntaxElement) -> Option<String> {
let NodeOrToken::Token(token) = element else {
return None;
};
match token.kind() {
SyntaxKind::IDENT => Some(token.text().to_string()),
SyntaxKind::STRING => strip_quotes(token.text()).map(str::to_string),
_ => None,
}
}
fn is_trivia(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
)
}
fn strip_quotes(text: &str) -> Option<&str> {
let bytes = text.as_bytes();
if bytes.len() >= 2 {
let (first, last) = (bytes[0], bytes[bytes.len() - 1]);
if (first == b'"' || first == b'\'' || first == b'`') && first == last {
return Some(&text[1..text.len() - 1]);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn edges(src: &str, base_dir: Option<&Path>) -> Vec<SourceEdge> {
collect_source_edges(&parse(src).cst, base_dir)
}
#[test]
fn resolves_relative_literal_against_base_dir() {
let base = PathBuf::from("/proj/R");
let e = edges("source(\"helpers.R\")\n", Some(&base));
assert_eq!(e.len(), 1);
assert_eq!(
e[0].target,
SourceTarget::Path(PathBuf::from("/proj/R/helpers.R"))
);
assert!(e[0].contributes_scope());
}
#[test]
fn keeps_absolute_literal_as_is() {
let base = PathBuf::from("/proj");
let e = edges("source(\"/abs/util.R\")\n", Some(&base));
assert_eq!(
e[0].target,
SourceTarget::Path(PathBuf::from("/abs/util.R"))
);
}
#[test]
fn relative_literal_without_base_dir_stays_relative() {
let e = edges("source(\"helpers.R\")\n", None);
assert_eq!(e[0].target, SourceTarget::Path(PathBuf::from("helpers.R")));
}
#[test]
fn named_file_argument_is_recognized() {
let e = edges("source(file = \"setup.R\")\n", None);
assert_eq!(e[0].target, SourceTarget::Path(PathBuf::from("setup.R")));
}
#[test]
fn local_true_does_not_contribute_scope() {
let e = edges("source(\"helpers.R\", local = TRUE)\n", None);
assert!(e[0].local);
assert!(!e[0].contributes_scope());
}
#[test]
fn dynamic_argument_is_unresolved() {
let e = edges("source(paste0(dir, \"x.R\"))\n", None);
assert_eq!(e[0].target, SourceTarget::Dynamic);
assert!(!e[0].contributes_scope());
let v = edges("source(path)\n", None);
assert_eq!(v[0].target, SourceTarget::Dynamic);
}
#[test]
fn source_inside_function_is_not_top_level() {
let e = edges("f <- function() source(\"x.R\")\n", None);
assert!(e.is_empty());
}
#[test]
fn non_source_calls_are_ignored() {
let e = edges("library(dplyr)\nprint(\"x.R\")\n", None);
assert!(e.is_empty());
}
fn literal_edges(src: &str, base_dir: Option<&Path>) -> Vec<SourceLiteralEdge> {
collect_source_literal_edges(&parse(src).cst, base_dir)
}
#[test]
fn literal_edge_captures_range_and_quoting() {
let src = "source(\"helpers.R\")\n";
let base = PathBuf::from("/proj/R");
let e = literal_edges(src, Some(&base));
assert_eq!(e.len(), 1);
assert_eq!(e[0].spelling, "helpers.R");
assert_eq!(e[0].quote, b'"');
assert!(e[0].was_relative);
assert_eq!(
e[0].target,
PathBuf::from("/proj/R/helpers.R"),
"relative literal resolves against base dir"
);
let range = e[0].literal_range;
assert_eq!(
&src[range.start().into()..range.end().into()],
"\"helpers.R\""
);
}
#[test]
fn literal_edge_preserves_single_quotes() {
let e = literal_edges("source('a.R')\n", None);
assert_eq!(e.len(), 1);
assert_eq!(e[0].quote, b'\'');
assert_eq!(e[0].spelling, "a.R");
}
#[test]
fn literal_edge_recognizes_named_file_argument() {
let e = literal_edges("source(file = \"setup.R\")\n", None);
assert_eq!(e.len(), 1);
assert_eq!(e[0].spelling, "setup.R");
}
#[test]
fn literal_edge_marks_absolute_spelling() {
let base = PathBuf::from("/proj");
let e = literal_edges("source(\"/abs/util.R\")\n", Some(&base));
assert_eq!(e.len(), 1);
assert!(!e[0].was_relative);
assert_eq!(e[0].target, PathBuf::from("/abs/util.R"));
}
#[test]
fn relativity_classification_is_host_independent() {
assert!(is_relative_spelling("helpers.R"));
assert!(is_relative_spelling("sub/helpers.R"));
assert!(!is_relative_spelling("/abs/util.R"));
assert!(!is_relative_spelling("\\abs\\util.R"));
assert!(!is_relative_spelling("C:\\abs\\util.R"));
assert!(!is_relative_spelling("C:/abs/util.R"));
}
#[test]
fn literal_edge_skips_dynamic_arguments() {
assert!(literal_edges("source(paste0(dir, \"x.R\"))\n", None).is_empty());
assert!(literal_edges("source(path)\n", None).is_empty());
}
#[test]
fn relative_path_same_directory() {
let r = relative_path(Path::new("/proj/R"), Path::new("/proj/R/a.R")).unwrap();
assert_eq!(r, PathBuf::from("a.R"));
}
#[test]
fn relative_path_child_directory() {
let r = relative_path(Path::new("/proj/R"), Path::new("/proj/R/sub/a.R")).unwrap();
assert_eq!(r, PathBuf::from("sub/a.R"));
}
#[test]
fn relative_path_parent_directory() {
let r = relative_path(Path::new("/proj/R/sub"), Path::new("/proj/R/a.R")).unwrap();
assert_eq!(r, PathBuf::from("../a.R"));
}
#[test]
fn relative_path_disjoint_subtree() {
let r = relative_path(Path::new("/proj/a/b"), Path::new("/proj/c/d.R")).unwrap();
assert_eq!(r, PathBuf::from("../../c/d.R"));
}
}