use harn_vm::VmValue;
use tree_sitter::{Node, Tree};
use crate::tools::args::{build_dict, str_value};
use super::language::Language;
use super::undefined_names::node_text;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ResolutionCeiling {
SingleFileComplete,
PackageScoped,
RuntimeResolved,
}
impl ResolutionCeiling {
fn as_str(self) -> &'static str {
match self {
Self::SingleFileComplete => "single_file_complete",
Self::PackageScoped => "package_scoped",
Self::RuntimeResolved => "runtime_resolved",
}
}
}
pub(super) fn ceiling(language: Language) -> ResolutionCeiling {
match language {
Language::Python
| Language::JavaScript
| Language::Jsx
| Language::TypeScript
| Language::Tsx
| Language::Rust => ResolutionCeiling::SingleFileComplete,
Language::Go => ResolutionCeiling::PackageScoped,
Language::Ruby => ResolutionCeiling::RuntimeResolved,
_ => ResolutionCeiling::RuntimeResolved,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Defeater {
WildcardImport,
DynamicNameAccess,
DynamicMethodDefinition,
Eval,
WithScope,
IdentifierNormalization,
SyntaxError,
}
impl Defeater {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::WildcardImport => "wildcard_import",
Self::DynamicNameAccess => "dynamic_name_access",
Self::DynamicMethodDefinition => "dynamic_method_definition",
Self::Eval => "eval",
Self::WithScope => "with_scope",
Self::IdentifierNormalization => "identifier_normalization",
Self::SyntaxError => "syntax_error",
}
}
}
#[derive(Debug, Clone)]
pub(super) struct Resolution {
ceiling: ResolutionCeiling,
defeaters: Vec<Defeater>,
analysed: bool,
}
impl Resolution {
pub(super) fn is_complete(&self) -> bool {
self.analysed
&& self.ceiling == ResolutionCeiling::SingleFileComplete
&& self.defeaters.is_empty()
}
pub(super) fn to_vm_value(&self) -> VmValue {
let defeaters: Vec<VmValue> = self
.defeaters
.iter()
.map(|d| str_value(d.as_str()))
.collect();
build_dict([
("complete", VmValue::Bool(self.is_complete())),
("analysed", VmValue::Bool(self.analysed)),
("ceiling", str_value(self.ceiling.as_str())),
("defeaters", VmValue::list(defeaters)),
])
}
pub(super) fn unanalysed(language: Language) -> Self {
Self {
ceiling: ceiling(language),
defeaters: Vec::new(),
analysed: false,
}
}
}
pub(super) fn assess(tree: &Tree, source: &str, language: Language) -> Resolution {
Resolution {
ceiling: ceiling(language),
defeaters: binding_defeaters(tree, source, language),
analysed: true,
}
}
pub(crate) fn binding_defeaters(tree: &Tree, source: &str, language: Language) -> Vec<Defeater> {
let mut defeaters: Vec<Defeater> = Vec::new();
let mut note = |d: Defeater| {
if !defeaters.contains(&d) {
defeaters.push(d);
}
};
if tree.root_node().has_error() {
note(Defeater::SyntaxError);
}
scan(tree.root_node(), source, language, &mut note);
defeaters
}
pub(crate) fn has_dynamic_class_lookup(node: Node<'_>, language: Language) -> bool {
if language != Language::Python {
return false;
}
let at = node.start_byte();
let mut in_function_body = false;
let mut outer = node.parent();
while let Some(parent) = outer {
let in_body = parent
.child_by_field_name("body")
.is_some_and(|body| body.byte_range().contains(&at));
if matches!(parent.kind(), "function_definition" | "lambda") && in_body {
in_function_body = true;
}
if parent.kind() == "class_definition" && in_body && !in_function_body {
return true;
}
outer = parent.parent();
}
false
}
const PYTHON_DYNAMIC_CALLS: &[&str] = &[
"eval", "exec", "globals", "locals", "setattr", "delattr", "vars", "compile",
];
const RUBY_DYNAMIC_CALLS: &[&str] = &[
"method_missing",
"define_method",
"const_missing",
"instance_variable_set",
"const_set",
"class_eval",
"instance_eval",
"attr_accessor",
"attr_reader",
"attr_writer",
];
fn scan(root: Node<'_>, source: &str, language: Language, note: &mut impl FnMut(Defeater)) {
let mut cursor = root.walk();
loop {
scan_node(cursor.node(), source, language, note);
if cursor.goto_first_child() {
continue;
}
while !cursor.goto_next_sibling() {
if !cursor.goto_parent() {
return;
}
}
}
}
fn scan_node(node: Node<'_>, source: &str, language: Language, note: &mut impl FnMut(Defeater)) {
if language
.rename_identifier_kinds()
.is_some_and(|kinds| kinds.contains(&node.kind()))
{
let spelling = node_text(node, source);
let opaque = match language {
Language::Python => !spelling.is_ascii(),
Language::Rust => !spelling.is_ascii() || spelling.starts_with("r#"),
Language::JavaScript | Language::Jsx | Language::TypeScript | Language::Tsx => {
spelling.contains('\\')
}
Language::Swift => spelling.starts_with('`'),
_ => false,
};
if opaque {
note(Defeater::IdentifierNormalization);
}
}
match (language, node.kind()) {
(Language::Python, "wildcard_import") => note(Defeater::WildcardImport),
(Language::Python, "call") => {
if let Some(name) = called_name(node, source) {
if PYTHON_DYNAMIC_CALLS.contains(&name) {
note(if name == "eval" || name == "exec" || name == "compile" {
Defeater::Eval
} else {
Defeater::DynamicNameAccess
});
}
}
}
(Language::Python, "function_definition") => {
if let Some(name) = node.child_by_field_name("name") {
if matches!(node_text(name, source), "__getattr__" | "__getattribute__") {
note(Defeater::DynamicNameAccess);
}
}
}
(Language::Ruby, "call") => {
if let Some(name) = called_name(node, source) {
if RUBY_DYNAMIC_CALLS.contains(&name) {
note(Defeater::DynamicMethodDefinition);
}
}
}
(Language::Ruby, "method") => {
if let Some(name) = node.child_by_field_name("name") {
if matches!(node_text(name, source), "method_missing" | "const_missing") {
note(Defeater::DynamicMethodDefinition);
}
}
}
(
Language::JavaScript | Language::Jsx | Language::TypeScript | Language::Tsx,
"call_expression",
) => {
if let Some(name) = called_name(node, source) {
if name == "eval" {
note(Defeater::Eval);
}
}
}
(
Language::JavaScript | Language::Jsx | Language::TypeScript | Language::Tsx,
"with_statement",
) => note(Defeater::WithScope),
(Language::Rust, "use_wildcard") => note(Defeater::WildcardImport),
(Language::Rust, "macro_invocation" | "attribute_item" | "inner_attribute_item")
if rust_expansion_may_bind(node, source) =>
{
note(Defeater::DynamicNameAccess);
}
(Language::Swift, "macro_invocation" | "attribute") => note(Defeater::DynamicNameAccess),
_ => {}
}
}
const RUST_ITEMLESS_STD_MACROS: &[&str] = &[
"assert",
"assert_eq",
"assert_ne",
"cfg",
"column",
"compile_error",
"concat",
"dbg",
"debug_assert",
"debug_assert_eq",
"debug_assert_ne",
"env",
"eprint",
"eprintln",
"file",
"format",
"format_args",
"include_bytes",
"include_str",
"line",
"matches",
"module_path",
"option_env",
"panic",
"print",
"println",
"stringify",
"todo",
"unimplemented",
"unreachable",
"vec",
"write",
"writeln",
];
const RUST_INERT_ATTRIBUTES: &[&str] = &[
"allow",
"automatically_derived",
"cfg",
"cfg_attr",
"clippy",
"cold",
"deny",
"deprecated",
"derive",
"diagnostic",
"doc",
"expect",
"export_name",
"forbid",
"ignore",
"inline",
"link_section",
"macro_export",
"must_use",
"no_mangle",
"non_exhaustive",
"repr",
"rustfmt",
"should_panic",
"test",
"track_caller",
"used",
"warn",
];
fn rust_expansion_may_bind(node: Node<'_>, source: &str) -> bool {
match node.kind() {
"macro_invocation" => {
let in_statement_position = node.parent().is_some_and(|parent| {
matches!(
parent.kind(),
"source_file" | "declaration_list" | "block" | "expression_statement"
)
});
in_statement_position && !rust_itemless_std_macro(node, source)
}
"attribute_item" | "inner_attribute_item" => {
let path = node
.named_child(0)
.and_then(|attribute| attribute.named_child(0))
.map_or("", |path| node_text(path, source));
let root = path.split("::").next().unwrap_or(path);
!RUST_INERT_ATTRIBUTES.contains(&root)
}
_ => false,
}
}
pub(crate) fn rust_itemless_std_macro(invocation: Node<'_>, source: &str) -> bool {
let Some(path) = invocation.child_by_field_name("macro") else {
return false;
};
let path = node_text(path, source);
let (prefix, name) = path.rsplit_once("::").unwrap_or(("", path));
matches!(
prefix.trim_start_matches("::"),
"" | "std" | "core" | "alloc"
) && RUST_ITEMLESS_STD_MACROS.contains(&name)
}
fn called_name<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
let mut callee = node
.child_by_field_name("function")
.or_else(|| node.child_by_field_name("method"))?;
while callee.kind() == "parenthesized_expression" {
let mut cursor = callee.walk();
callee = callee
.named_children(&mut cursor)
.find(|child| !child.is_extra())?;
}
match callee.kind() {
"identifier" => Some(node_text(callee, source)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::parse::parse_source;
fn assess_source(source: &str, language: Language) -> Resolution {
let tree = parse_source(source, language).expect("parses");
assess(&tree, source, language)
}
fn defeater_names(resolution: &Resolution) -> Vec<&'static str> {
resolution.defeaters.iter().map(|d| d.as_str()).collect()
}
#[test]
fn python_dynamic_name_calls_defeat_resolution() {
let resolution = assess_source("def f(t):\n setattr(t, 'a', 1)\n", Language::Python);
assert!(!resolution.is_complete());
assert_eq!(defeater_names(&resolution), vec!["dynamic_name_access"]);
}
#[test]
fn python_module_getattr_defeats_resolution() {
let resolution = assess_source(
"def __getattr__(name):\n return name\n",
Language::Python,
);
assert!(!resolution.is_complete());
assert_eq!(defeater_names(&resolution), vec!["dynamic_name_access"]);
}
#[test]
fn python_method_named_like_a_builtin_is_not_a_defeater() {
let resolution = assess_source("def f(o):\n o.setattr('a', 1)\n", Language::Python);
assert!(resolution.is_complete());
}
#[test]
fn ruby_is_never_complete_even_when_plainly_written() {
let resolution = assess_source("def total(a)\n a + 1\nend\n", Language::Ruby);
assert!(!resolution.is_complete());
assert_eq!(resolution.ceiling.as_str(), "runtime_resolved");
assert!(defeater_names(&resolution).is_empty());
}
#[test]
fn ruby_dynamic_definition_is_named_as_a_defeater() {
let resolution = assess_source(
"class A\n def method_missing(n)\n n\n end\nend\n",
Language::Ruby,
);
assert!(!resolution.is_complete());
assert_eq!(
defeater_names(&resolution),
vec!["dynamic_method_definition"]
);
}
#[test]
fn go_is_package_scoped_because_sibling_files_contribute_names() {
let resolution = assess_source("package main\n\nfunc f() int { return 1 }\n", Language::Go);
assert!(!resolution.is_complete());
assert_eq!(resolution.ceiling.as_str(), "package_scoped");
}
#[test]
fn javascript_eval_defeats_resolution() {
let resolution = assess_source("function f(s) { return eval(s); }\n", Language::JavaScript);
assert!(!resolution.is_complete());
assert_eq!(defeater_names(&resolution), vec!["eval"]);
}
#[test]
fn parenthesized_eval_still_defeats_resolution() {
for source in [
"function f(s) { return ((eval))(s); }\n",
"function f(s) { return ((/* comment */ eval))(s); }\n",
] {
let resolution = assess_source(source, Language::JavaScript);
assert!(!resolution.is_complete());
assert_eq!(defeater_names(&resolution), vec!["eval"]);
}
}
#[test]
fn identifier_spellings_that_require_normalization_are_not_complete() {
for (language, source) in [
(
Language::Python,
"def probe(ℝeading):\n return Reading()\n",
),
(
Language::Rust,
"fn probe() { let r#Reading = || 7; Reading(); }\n",
),
(
Language::JavaScript,
"function probe() { const Read\\u0069ng = () => 7; return Reading(); }\n",
),
] {
let resolution = assess_source(source, language);
assert!(!resolution.is_complete());
assert_eq!(
defeater_names(&resolution),
vec!["identifier_normalization"]
);
}
}
#[test]
fn rust_macro_expansion_is_not_a_complete_binding_analysis() {
for source in [
"fn f() { binding_macro::bind!(); Reading() }\n",
"#[binding_macro::bind]\nfn f() { Reading() }\n",
] {
let resolution = assess_source(source, Language::Rust);
assert!(!resolution.is_complete());
assert_eq!(defeater_names(&resolution), vec!["dynamic_name_access"]);
}
}
#[test]
fn itemless_rust_macros_and_inert_attributes_keep_resolution_complete() {
let source = "#![allow(dead_code)]\n#[derive(Debug, Clone)]\npub struct Reading;\n\
#[cfg(test)]\nmod tests {\n #[test]\n fn t() { assert_eq!(1, 1); println!(\"{}\", 2); }\n}\n\
fn f() -> String { let v = vec![1]; std::format!(\"{v:?}\") }\n\
fn g() -> usize { my_crate::count!(a, b) + 1 }\n";
let resolution = assess_source(source, Language::Rust);
assert!(
defeater_names(&resolution).is_empty(),
"{:?}",
defeater_names(&resolution)
);
for source in [
"fn f() { my_crate::bind!(); }\n",
"fn f() { other::println!(); }\n",
"include!(\"generated.rs\");\n",
"#[tokio::main]\nasync fn main() {}\n",
] {
assert_eq!(
defeater_names(&assess_source(source, Language::Rust)),
vec!["dynamic_name_access"],
"{source}"
);
}
}
#[test]
fn swift_expansion_is_not_a_complete_binding_analysis() {
for source in [
"func probe() { #declareReading(); Reading() }\n",
"@GenerateBindings func probe() { Reading() }\n",
] {
let resolution = assess_source(source, Language::Swift);
assert_eq!(defeater_names(&resolution), vec!["dynamic_name_access"]);
}
}
#[test]
fn plain_javascript_file_resolves_completely() {
let resolution = assess_source(
"export function f(a) { return a + 1; }\n",
Language::JavaScript,
);
assert!(resolution.is_complete());
}
#[test]
fn defeaters_are_reported_once_each() {
let resolution = assess_source(
"from a import *\nfrom b import *\n\n\ndef f(t):\n setattr(t, 'x', 1)\n setattr(t, 'y', 2)\n",
Language::Python,
);
assert_eq!(
defeater_names(&resolution),
vec!["wildcard_import", "dynamic_name_access"]
);
}
#[test]
fn unanalysed_file_is_never_complete() {
assert!(!Resolution::unanalysed(Language::Python).is_complete());
}
#[test]
fn a_file_that_does_not_parse_cleanly_is_not_complete() {
let resolution = assess_source("def f(:\n return zzz\n", Language::Python);
assert!(!resolution.is_complete());
assert!(defeater_names(&resolution).contains(&"syntax_error"));
}
}