use super::language::ImportLanguage;
use crate::error::{CodeLoreError, Result};
use tree_sitter::{Node, Parser, TreeCursor};
#[derive(Debug, Clone)]
pub struct RawImport {
pub target: String,
pub kind: ImportKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportKind {
Absolute,
Relative,
Wildcard,
Unknown,
}
impl ImportKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Absolute => "absolute",
Self::Relative => "relative",
Self::Wildcard => "wildcard",
Self::Unknown => "unknown",
}
}
}
pub fn extract_imports(source: &[u8], lang: ImportLanguage) -> Result<Vec<RawImport>> {
let mut parser = Parser::new();
parser
.set_language(&lang.language())
.map_err(|e| CodeLoreError::Analysis(format!("set_language: {e}")))?;
let Some(tree) = parser.parse(source, None) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
walk_imports(tree.root_node(), source, lang, &mut out);
Ok(out)
}
fn walk_imports(root: Node<'_>, source: &[u8], lang: ImportLanguage, out: &mut Vec<RawImport>) {
let kinds = lang.import_node_kinds();
let mut cursor: TreeCursor<'_> = root.walk();
loop {
let current = cursor.node();
if kinds.contains(¤t.kind()) {
match lang {
ImportLanguage::Rust => collect_rust_imports(current, source, out),
ImportLanguage::JavaScript | ImportLanguage::TypeScript | ImportLanguage::Tsx => {
collect_js_imports(current, source, lang, out);
}
ImportLanguage::Python => collect_python_imports(current, source, out),
ImportLanguage::Java => {
if let Some(raw) = node_text(current, source)
&& let Some(target) = normalise_target(&raw, lang)
&& !target.is_empty()
{
let kind = classify(&target, lang);
out.push(RawImport { target, kind });
}
}
}
}
if cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() || cursor.node().id() == root.id() {
return;
}
}
}
}
fn collect_rust_imports(decl: Node<'_>, source: &[u8], out: &mut Vec<RawImport>) {
if in_cfg_test_module(decl, source) {
return;
}
let Some(argument) = decl.child_by_field_name("argument") else {
return;
};
let mut targets = Vec::new();
push_use_targets(argument, source, "", &mut targets);
for target in targets {
if target.is_empty() {
continue;
}
let kind = classify(&target, ImportLanguage::Rust);
out.push(RawImport { target, kind });
}
}
fn push_use_targets(node: Node<'_>, source: &[u8], prefix: &str, out: &mut Vec<String>) {
match node.kind() {
"scoped_use_list" => {
let inner = node
.child_by_field_name("path")
.and_then(|p| use_leaf_text(p, source));
let new_prefix =
inner.map_or_else(|| prefix.to_string(), |p| join_use_path(prefix, &p));
if let Some(list) = node.child_by_field_name("list") {
push_use_targets(list, source, &new_prefix, out);
}
}
"use_list" => {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
push_use_targets(child, source, prefix, out);
}
}
"use_as_clause" => {
if let Some(path) = node.child_by_field_name("path") {
push_use_targets(path, source, prefix, out);
}
}
"self" => {
if !prefix.is_empty() {
out.push(prefix.to_string());
}
}
_ => {
if let Some(text) = use_leaf_text(node, source) {
out.push(join_use_path(prefix, &text));
}
}
}
}
fn use_leaf_text(node: Node<'_>, source: &[u8]) -> Option<String> {
node_text(node, source).map(|t| t.split_whitespace().collect::<String>())
}
fn join_use_path(prefix: &str, segment: &str) -> String {
if prefix.is_empty() {
segment.to_string()
} else if segment.is_empty() {
prefix.to_string()
} else {
format!("{prefix}::{segment}")
}
}
fn in_cfg_test_module(node: Node<'_>, source: &[u8]) -> bool {
let mut ancestor = node.parent();
while let Some(current) = ancestor {
if current.kind() == "mod_item" && mod_is_cfg_test(current, source) {
return true;
}
ancestor = current.parent();
}
false
}
fn mod_is_cfg_test(mod_item: Node<'_>, source: &[u8]) -> bool {
let mut sibling = mod_item.prev_sibling();
while let Some(node) = sibling {
match node.kind() {
"attribute_item" | "inner_attribute_item" => {
if attr_gates_test(node, source) {
return true;
}
}
"line_comment" | "block_comment" => {}
_ => break,
}
sibling = node.prev_sibling();
}
if let Some(body) = mod_item.child_by_field_name("body") {
let mut cursor = body.walk();
for child in body.named_children(&mut cursor) {
if child.kind() != "inner_attribute_item" {
break;
}
if attr_gates_test(child, source) {
return true;
}
}
}
false
}
fn attr_gates_test(node: Node<'_>, source: &[u8]) -> bool {
node_text(node, source).is_some_and(|t| {
t.split_whitespace()
.collect::<String>()
.contains("cfg(test)")
})
}
fn collect_js_imports(
node: Node<'_>,
source: &[u8],
lang: ImportLanguage,
out: &mut Vec<RawImport>,
) {
match node.kind() {
"import_statement" => {
if let Some(src_node) = node.child_by_field_name("source") {
push_js_specifier(src_node, source, lang, out);
} else if let Some(clause) = named_child_of_kind(node, "import_require_clause")
&& let Some(src_node) = clause.child_by_field_name("source")
{
push_js_specifier(src_node, source, lang, out);
}
}
"export_statement" => {
if let Some(src_node) = node.child_by_field_name("source") {
push_js_specifier(src_node, source, lang, out);
}
}
"call_expression" => {
let Some(func) = node.child_by_field_name("function") else {
return;
};
let is_module_call = func.kind() == "import"
|| (func.kind() == "identifier"
&& node_text(func, source).as_deref() == Some("require"));
if !is_module_call {
return;
}
let Some(args) = node.child_by_field_name("arguments") else {
return;
};
let mut cursor = args.walk();
if let Some(first) = args.named_children(&mut cursor).next()
&& first.kind() == "string"
{
push_js_specifier(first, source, lang, out);
}
}
_ => {}
}
}
fn push_js_specifier(
string_node: Node<'_>,
source: &[u8],
lang: ImportLanguage,
out: &mut Vec<RawImport>,
) {
let Some(fragment) = named_child_of_kind(string_node, "string_fragment") else {
return;
};
let Some(target) = node_text(fragment, source) else {
return;
};
if target.is_empty() {
return;
}
let kind = classify(&target, lang);
out.push(RawImport { target, kind });
}
fn named_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
let mut cursor = node.walk();
node.named_children(&mut cursor).find(|c| c.kind() == kind)
}
fn node_text<'a>(node: Node<'a>, source: &'a [u8]) -> Option<String> {
let start = node.start_byte();
let end = node.end_byte();
if end > source.len() || start > end {
return None;
}
let slice = &source[start..end];
std::str::from_utf8(slice).ok().map(str::to_string)
}
fn collect_python_imports(node: Node<'_>, source: &[u8], out: &mut Vec<RawImport>) {
match node.kind() {
"import_statement" => {
let mut cursor = node.walk();
for name in node.children_by_field_name("name", &mut cursor) {
if let Some(target) = python_name_text(name, source) {
push_python_target(target, out);
}
}
}
"import_from_statement" => {
let Some(module) = node.child_by_field_name("module_name") else {
return;
};
if module.kind() == "dotted_name" {
if let Some(target) = node_text(module, source) {
push_python_target(target, out);
}
return;
}
let Some(dots) =
named_child_of_kind(module, "import_prefix").and_then(|p| node_text(p, source))
else {
return;
};
if let Some(tail) = named_child_of_kind(module, "dotted_name") {
if let Some(t) = node_text(tail, source) {
push_python_target(format!("{dots}{t}"), out);
}
} else {
let mut cursor = node.walk();
for name in node.children_by_field_name("name", &mut cursor) {
if let Some(t) = python_name_text(name, source) {
push_python_target(format!("{dots}{t}"), out);
}
}
}
}
_ => {}
}
}
fn python_name_text(name: Node<'_>, source: &[u8]) -> Option<String> {
let path = if name.kind() == "aliased_import" {
name.child_by_field_name("name")?
} else {
name
};
node_text(path, source)
}
fn push_python_target(target: String, out: &mut Vec<RawImport>) {
if target.is_empty() {
return;
}
let kind = classify(&target, ImportLanguage::Python);
out.push(RawImport { target, kind });
}
fn normalise_target(raw: &str, lang: ImportLanguage) -> Option<String> {
let s = raw.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = match lang {
ImportLanguage::Rust => {
debug_assert!(false, "rust imports bypass normalise_target");
return None;
}
ImportLanguage::JavaScript | ImportLanguage::TypeScript | ImportLanguage::Tsx => {
debug_assert!(false, "js/ts imports bypass normalise_target");
return None;
}
ImportLanguage::Python => {
debug_assert!(false, "python imports bypass normalise_target");
return None;
}
ImportLanguage::Java => s
.trim_start_matches("import ")
.trim_start_matches("static ")
.trim_end_matches(';')
.trim()
.to_string(),
};
if trimmed.is_empty() {
return None;
}
Some(trimmed)
}
fn classify(target: &str, lang: ImportLanguage) -> ImportKind {
if target.is_empty() {
return ImportKind::Unknown;
}
if target.ends_with('*') || target.contains("::*") {
return ImportKind::Wildcard;
}
let relative_root = match lang {
ImportLanguage::Rust => {
target.starts_with("crate::")
|| target.starts_with("super::")
|| target.starts_with("self::")
}
ImportLanguage::Python => target.starts_with('.'),
ImportLanguage::JavaScript | ImportLanguage::TypeScript | ImportLanguage::Tsx => {
target.starts_with("./") || target.starts_with("../")
}
ImportLanguage::Java => false, };
if relative_root {
ImportKind::Relative
} else {
ImportKind::Absolute
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rust_use_declaration_extracts_absolute_target() {
let src = b"use std::fs::read_to_string;\nfn main() {}";
let got = extract_imports(src, ImportLanguage::Rust).unwrap();
assert_eq!(got.len(), 1, "one import expected");
assert_eq!(got[0].target, "std::fs::read_to_string");
assert_eq!(got[0].kind, ImportKind::Absolute);
}
#[test]
fn rust_crate_relative_classifies_as_relative() {
let src = b"use crate::analyses::hotspots;";
let got = extract_imports(src, ImportLanguage::Rust).unwrap();
assert_eq!(got[0].kind, ImportKind::Relative);
}
#[test]
fn rust_glob_classifies_as_wildcard() {
let src = b"use std::collections::*;";
let got = extract_imports(src, ImportLanguage::Rust).unwrap();
assert_eq!(got[0].kind, ImportKind::Wildcard);
}
#[test]
fn python_from_extracts_module_target() {
let src = b"from os.path import join\n";
let got = extract_imports(src, ImportLanguage::Python).unwrap();
assert!(!got.is_empty(), "expected at least one import row");
assert!(
got.iter().any(|r| r.target.contains("os.path")),
"expected os.path target, got {got:?}"
);
}
fn py_edges(src: &str) -> Vec<(String, ImportKind)> {
let mut got: Vec<(String, ImportKind)> =
extract_imports(src.as_bytes(), ImportLanguage::Python)
.unwrap()
.into_iter()
.map(|r| (r.target, r.kind))
.collect();
got.sort_by(|a, b| a.0.cmp(&b.0));
got
}
#[test]
fn python_bare_dot_import_yields_sibling_edge() {
assert_eq!(
py_edges("from . import x\n"),
vec![(".x".to_string(), ImportKind::Relative)],
);
}
#[test]
fn python_bare_dot_import_fans_out_per_name() {
assert_eq!(
py_edges("from . import x, y\n"),
vec![
(".x".to_string(), ImportKind::Relative),
(".y".to_string(), ImportKind::Relative),
],
);
}
#[test]
fn python_relative_module_import_keeps_dotted_tail() {
assert_eq!(
py_edges("from .mod import y\n"),
vec![(".mod".to_string(), ImportKind::Relative)],
);
}
#[test]
fn python_parent_relative_import_keeps_double_dot() {
assert_eq!(
py_edges("from ..pkg import z\n"),
vec![("..pkg".to_string(), ImportKind::Relative)],
);
}
#[test]
fn python_absolute_from_import_is_absolute_edge() {
assert_eq!(
py_edges("from mypkg.utils import calc\n"),
vec![("mypkg.utils".to_string(), ImportKind::Absolute)],
);
}
#[test]
fn python_bare_imports_yield_one_edge_each() {
assert_eq!(
py_edges("import os\nimport pkg.sub\n"),
vec![
("os".to_string(), ImportKind::Absolute),
("pkg.sub".to_string(), ImportKind::Absolute),
],
);
}
#[test]
fn python_multi_import_fans_out() {
assert_eq!(
py_edges("import a, b.c\n"),
vec![
("a".to_string(), ImportKind::Absolute),
("b.c".to_string(), ImportKind::Absolute),
],
);
}
#[test]
fn python_aliased_import_drops_alias() {
assert_eq!(
py_edges("import numpy as np\n"),
vec![("numpy".to_string(), ImportKind::Absolute)],
);
}
#[test]
fn python_parenthesised_bare_dot_import_fans_out() {
assert_eq!(
py_edges("from . import (\n a,\n b,\n)\n"),
vec![
(".a".to_string(), ImportKind::Relative),
(".b".to_string(), ImportKind::Relative),
],
);
}
#[test]
fn python_future_import_yields_no_edge() {
assert!(py_edges("from __future__ import annotations\n").is_empty());
}
#[test]
fn javascript_from_extracts_string_literal() {
let src = b"import { useState } from 'react';\n";
let got = extract_imports(src, ImportLanguage::JavaScript).unwrap();
assert_eq!(got.len(), 1, "one import expected");
assert_eq!(got[0].target, "react");
assert_eq!(got[0].kind, ImportKind::Absolute);
}
#[test]
fn javascript_relative_path_classifies_as_relative() {
let src = b"import foo from './bar/baz';\n";
let got = extract_imports(src, ImportLanguage::JavaScript).unwrap();
assert_eq!(got[0].kind, ImportKind::Relative);
}
fn js_edges(src: &str, lang: ImportLanguage) -> Vec<(String, ImportKind)> {
extract_imports(src.as_bytes(), lang)
.unwrap()
.into_iter()
.map(|r| (r.target, r.kind))
.collect()
}
#[test]
fn js_reexport_named_captures_edge() {
let got = js_edges("export { a } from './x';\n", ImportLanguage::TypeScript);
assert_eq!(got, vec![("./x".to_string(), ImportKind::Relative)]);
}
#[test]
fn js_reexport_star_captures_edge() {
let got = js_edges("export * from './x';\n", ImportLanguage::TypeScript);
assert_eq!(got, vec![("./x".to_string(), ImportKind::Relative)]);
}
#[test]
fn js_plain_export_const_yields_no_edge() {
let got = js_edges("export const x = 1;\n", ImportLanguage::TypeScript);
assert!(got.is_empty(), "plain export must not surface, got {got:?}");
}
#[test]
fn js_require_captures_edge() {
let got = js_edges("const x = require('./x');\n", ImportLanguage::JavaScript);
assert_eq!(got, vec![("./x".to_string(), ImportKind::Relative)]);
}
#[test]
fn js_dynamic_import_captures_edge() {
let got = js_edges("const x = import('./x');\n", ImportLanguage::JavaScript);
assert_eq!(got, vec![("./x".to_string(), ImportKind::Relative)]);
}
#[test]
fn js_side_effect_import_captures_edge() {
let got = js_edges("import './x';\n", ImportLanguage::JavaScript);
assert_eq!(got, vec![("./x".to_string(), ImportKind::Relative)]);
}
#[test]
fn js_minified_import_captures_edge() {
let got = js_edges("import{a}from\"./x\";", ImportLanguage::JavaScript);
assert_eq!(got, vec![("./x".to_string(), ImportKind::Relative)]);
}
#[test]
fn js_require_of_variable_yields_no_edge() {
let got = js_edges("const x = require(someVar);\n", ImportLanguage::JavaScript);
assert!(got.is_empty(), "require(variable) must not surface");
}
#[test]
fn js_dynamic_import_of_template_yields_no_edge() {
let got = js_edges("const x = import(`./x`);\n", ImportLanguage::JavaScript);
assert!(got.is_empty(), "template specifier must not surface");
}
#[test]
fn ts_import_require_clause_captures_edge() {
let got = js_edges("import x = require('./y');\n", ImportLanguage::TypeScript);
assert_eq!(got, vec![("./y".to_string(), ImportKind::Relative)]);
}
#[test]
fn ts_variant_routes_through_ast_extraction() {
let got = js_edges(
"import { X } from 'pkg';\nexport { Y } from './y';\n",
ImportLanguage::TypeScript,
);
assert_eq!(
got,
vec![
("pkg".to_string(), ImportKind::Absolute),
("./y".to_string(), ImportKind::Relative),
],
);
}
#[test]
fn tsx_variant_captures_side_effect_import() {
let got = js_edges("import './styles.css';\n", ImportLanguage::Tsx);
assert_eq!(
got,
vec![("./styles.css".to_string(), ImportKind::Relative)]
);
}
#[test]
fn js_empty_specifier_yields_no_edge() {
let got = js_edges("import \"\";\n", ImportLanguage::JavaScript);
assert!(got.is_empty(), "empty specifier must not surface");
}
#[test]
fn java_import_declaration_extracts_target() {
let src = b"package com.example;\nimport java.util.List;\nclass A {}";
let got = extract_imports(src, ImportLanguage::Java).unwrap();
assert!(got.iter().any(|r| r.target == "java.util.List"));
}
#[test]
fn java_wildcard_classifies_correctly() {
let src = b"import java.util.*;";
let got = extract_imports(src, ImportLanguage::Java).unwrap();
assert_eq!(got[0].kind, ImportKind::Wildcard);
}
#[test]
fn empty_source_yields_no_imports() {
let src = b"";
let got = extract_imports(src, ImportLanguage::Rust).unwrap();
assert!(got.is_empty());
}
#[test]
fn source_without_imports_yields_no_rows() {
let src = b"fn main() { let x = 1; }";
let got = extract_imports(src, ImportLanguage::Rust).unwrap();
assert!(got.is_empty());
}
fn rust_targets(src: &[u8]) -> Vec<String> {
let mut got: Vec<String> = extract_imports(src, ImportLanguage::Rust)
.unwrap()
.into_iter()
.map(|r| r.target)
.collect();
got.sort();
got
}
#[test]
fn rust_top_level_group_expands_to_each_leaf() {
assert_eq!(
rust_targets(b"use crate::{a, b};"),
vec!["crate::a".to_string(), "crate::b".to_string()],
);
}
#[test]
fn rust_nested_group_expands_with_full_paths() {
assert_eq!(
rust_targets(b"use a::{b::{c, d}, e};"),
vec![
"a::b::c".to_string(),
"a::b::d".to_string(),
"a::e".to_string(),
],
);
}
#[test]
fn rust_self_in_group_emits_parent_module() {
assert_eq!(
rust_targets(b"use crate::foo::{self, Bar};"),
vec!["crate::foo".to_string(), "crate::foo::Bar".to_string()],
);
}
#[test]
fn rust_pub_crate_visibility_is_stripped() {
let got = extract_imports(b"pub(crate) use x::y;", ImportLanguage::Rust).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].target, "x::y");
assert_eq!(got[0].kind, ImportKind::Absolute);
}
#[test]
fn rust_pub_use_reexport_is_captured() {
let got = extract_imports(b"pub use crate::foo::Bar;", ImportLanguage::Rust).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].target, "crate::foo::Bar");
assert_eq!(got[0].kind, ImportKind::Relative);
}
#[test]
fn rust_use_as_clause_drops_alias() {
let got = extract_imports(b"use a::b as c;", ImportLanguage::Rust).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].target, "a::b");
}
#[test]
fn rust_wildcard_emits_module_with_wildcard_kind() {
let got = extract_imports(b"use std::collections::*;", ImportLanguage::Rust).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].target, "std::collections::*");
assert_eq!(got[0].kind, ImportKind::Wildcard);
}
#[test]
fn rust_cfg_test_module_import_is_skipped() {
let src = b"#[cfg(test)]\nmod tests {\n use super::x;\n}\n";
let got = extract_imports(src, ImportLanguage::Rust).unwrap();
assert!(
got.is_empty(),
"cfg(test) imports must not surface, got {got:?}"
);
}
#[test]
fn rust_production_module_import_is_kept() {
let src = b"mod inner {\n use super::x;\n}\n";
let got = extract_imports(src, ImportLanguage::Rust).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].target, "super::x");
assert_eq!(got[0].kind, ImportKind::Relative);
}
}