use anyhow::Result;
use code_ranker_plugin_api::{
attrs::{AttrValue, ValueType},
default_cycle_kinds, default_node_kinds,
edge::Edge,
graph::Graph,
level::{AttributeSpec, EdgeKindSpec, Level},
node::Node,
plugin::{LanguagePlugin, PluginInput},
};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub fn ecmascript_level(name: &str) -> Level {
let mut edge_kinds = BTreeMap::new();
edge_kinds.insert(
"uses".to_string(),
EdgeKindSpec {
flow: true,
label: Some("uses".to_string()),
description: Some(
"Import dependency \u{2014} this file imports from the other.".to_string(),
),
},
);
let mut node_attributes = BTreeMap::new();
node_attributes.insert(
"path".to_string(),
AttributeSpec::new(ValueType::Str, "Path"),
);
node_attributes.insert(
"loc".to_string(),
AttributeSpec::new(ValueType::Int, "Lines"),
);
node_attributes.insert(
"visibility".to_string(),
AttributeSpec::new(ValueType::Str, "Visibility"),
);
node_attributes.insert(
"external".to_string(),
AttributeSpec::new(ValueType::Bool, "External"),
);
Level {
name: name.to_string(),
edge_kinds,
node_attributes,
edge_attributes: BTreeMap::new(),
attribute_groups: BTreeMap::new(),
node_kinds: default_node_kinds(),
cycle_kinds: default_cycle_kinds(),
grouping: None,
}
}
pub fn detect_with_marker(workspace: &Path, marker: &str) -> bool {
workspace.join(marker).exists()
}
pub fn analyze_ecmascript(
workspace: &Path,
exts: &[&str],
lang_for_ext: impl Fn(&str) -> Option<tree_sitter::Language>,
candidate_exts_order: &[&str],
ignore_tests: bool,
) -> Result<Graph> {
let source_root = find_source_root(workspace);
let alias_root = source_root.clone();
let files = collect_files(&source_root, exts, ignore_tests);
let file_index = build_file_index(workspace, &files);
let mut nodes: Vec<Node> = Vec::new();
let mut edges: Vec<Edge> = Vec::new();
let mut ext_seen: HashMap<String, ()> = HashMap::new();
let mut file_ids_seen: HashMap<String, ()> = HashMap::new();
for abs_path in &files {
let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let language = match lang_for_ext(ext) {
Some(l) => l,
None => continue,
};
let source = match std::fs::read(abs_path) {
Ok(s) => s,
Err(_) => continue,
};
let mut ts_parser = tree_sitter::Parser::new();
ts_parser
.set_language(&language)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let tree = match ts_parser.parse(&source, None) {
Some(t) => t,
None => continue,
};
let loc = source.iter().filter(|&&b| b == b'\n').count() as i64 + 1;
let file_id = abs_path.to_string_lossy().into_owned();
if !file_ids_seen.contains_key(&file_id) {
file_ids_seen.insert(file_id.clone(), ());
let mut attrs = BTreeMap::new();
attrs.insert(
"visibility".to_string(),
AttrValue::Str("public".to_string()),
);
attrs.insert("loc".to_string(), AttrValue::Int(loc));
nodes.push(Node {
id: file_id.clone(),
kind: "file".to_string(),
name: abs_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
parent: None,
attrs,
});
}
let specifiers = extract_import_specifiers(&tree.root_node(), &source);
for (spec, line) in &specifiers {
if let Some(target) = resolve_import(
spec,
abs_path,
workspace,
&alias_root,
&file_index,
candidate_exts_order,
) {
let target_id = target.to_string_lossy().into_owned();
if target_id != file_id {
edges.push(Edge {
source: file_id.clone(),
target: target_id,
kind: "uses".to_string(),
line: Some(*line),
attrs: BTreeMap::new(),
});
}
} else if let Some(pkg) = external_package(spec) {
let ext_id = format!("ext:{pkg}");
if !ext_seen.contains_key(&ext_id) {
ext_seen.insert(ext_id.clone(), ());
let mut attrs = BTreeMap::new();
attrs.insert("external".to_string(), AttrValue::Bool(true));
nodes.push(Node {
id: ext_id.clone(),
kind: "external".to_string(),
name: pkg,
parent: None,
attrs,
});
}
edges.push(Edge {
source: file_id.clone(),
target: ext_id,
kind: "uses".to_string(),
line: Some(*line),
attrs: BTreeMap::new(),
});
}
}
}
Ok(Graph { nodes, edges })
}
fn find_source_root(workspace: &Path) -> PathBuf {
let src = workspace.join("src");
if src.is_dir() {
src
} else {
workspace.to_owned()
}
}
fn collect_files(root: &Path, exts: &[&str], ignore_tests: bool) -> Vec<PathBuf> {
WalkDir::new(root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().is_file()
&& e.path()
.extension()
.is_some_and(|x| exts.contains(&x.to_str().unwrap_or("")))
&& !is_skip_path(e.path(), root)
&& !(ignore_tests && is_test_file(e.path(), root))
})
.map(|e| e.into_path())
.collect()
}
pub fn ecmascript_is_test_path(rel_path: &str) -> bool {
let file = rel_path.rsplit('/').next().unwrap_or(rel_path);
let stem = file.split('.').next().unwrap_or(file);
rel_path
.split('/')
.any(|c| matches!(c, "__tests__" | "__mocks__" | "tests" | "test"))
|| file.contains(".test.")
|| file.contains(".spec.")
|| stem.ends_with("_test")
|| stem.ends_with("_spec")
}
fn is_test_file(path: &Path, root: &Path) -> bool {
path.strip_prefix(root)
.ok()
.map(|rel| ecmascript_is_test_path(&rel.to_string_lossy().replace('\\', "/")))
.unwrap_or(false)
}
fn is_skip_path(path: &Path, workspace: &Path) -> bool {
path.strip_prefix(workspace)
.map(|rel| {
rel.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
s == "node_modules"
|| s == "dist"
|| s == "target"
|| s == "build"
|| s == "out"
|| s == ".venv"
|| s == "__pycache__"
|| s.starts_with('.')
|| s.ends_with(".gen.ts")
|| s.ends_with(".config.ts")
|| s.ends_with(".config.js")
|| s.ends_with(".min.js")
|| s.ends_with(".min.ts")
|| s.ends_with(".umd.js")
|| s.ends_with(".bundle.js")
})
})
.unwrap_or(false)
}
fn file_to_mod_path(workspace: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(workspace).ok()?;
let mut parts: Vec<String> = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
let last = parts.last_mut()?;
for ext in &[".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".mts", ".cts"] {
if let Some(stem) = last.strip_suffix(ext) {
*last = stem.to_string();
break;
}
}
if parts.last().map(|s| s == "index").unwrap_or(false) {
parts.pop();
}
if parts.is_empty() {
return None;
}
Some(parts.join("/"))
}
fn build_file_index(workspace: &Path, files: &[PathBuf]) -> HashMap<String, PathBuf> {
files
.iter()
.filter_map(|p| file_to_mod_path(workspace, p).map(|m| (m, p.clone())))
.collect()
}
pub fn external_package(spec: &str) -> Option<String> {
if spec.starts_with("./")
|| spec.starts_with("../")
|| spec.starts_with("@/")
|| spec.is_empty()
{
return None;
}
let mut it = spec.split('/');
let first = it.next().unwrap_or(spec);
if first.starts_with('@') {
match it.next() {
Some(second) => Some(format!("{first}/{second}")),
None => Some(first.to_string()),
}
} else {
Some(first.to_string())
}
}
fn extract_import_specifiers(root: &tree_sitter::Node, source: &[u8]) -> Vec<(String, u32)> {
let mut specs = Vec::new();
visit_imports(root, source, &mut specs);
specs
}
fn visit_imports<'t>(node: &tree_sitter::Node<'t>, source: &[u8], specs: &mut Vec<(String, u32)>) {
let mut cursor = node.walk();
let children: Vec<tree_sitter::Node<'t>> = node.children(&mut cursor).collect();
for child in &children {
let line = child.start_position().row as u32 + 1;
match child.kind() {
"import_statement" => {
if let Some(src) = import_source(child, source) {
specs.push((src, line));
}
}
"export_statement" => {
if let Some(src) = import_source(child, source) {
specs.push((src, line));
}
visit_imports(child, source, specs);
}
"call_expression" => {
if let Some(src) = require_source(child, source) {
specs.push((src, line));
} else {
visit_imports(child, source, specs);
}
}
_ => visit_imports(child, source, specs),
}
}
}
fn import_source(node: &tree_sitter::Node, source: &[u8]) -> Option<String> {
let mut cursor = node.walk();
let children: Vec<_> = node.children(&mut cursor).collect();
for child in children.iter().rev() {
if child.kind() == "string"
&& let Ok(raw) = child.utf8_text(source)
{
let trimmed = raw.trim_matches(|c| c == '\'' || c == '"' || c == '`');
return Some(trimmed.to_string());
}
}
None
}
fn require_source(node: &tree_sitter::Node, source: &[u8]) -> Option<String> {
let fn_node = node.child_by_field_name("function")?;
let fn_text = fn_node.utf8_text(source).ok()?;
if fn_text != "require" {
return None;
}
let args = node.child_by_field_name("arguments")?;
let mut cursor = args.walk();
for child in args.children(&mut cursor) {
if child.kind() == "string"
&& let Ok(raw) = child.utf8_text(source)
{
let trimmed = raw.trim_matches(|c| c == '\'' || c == '"' || c == '`');
return Some(trimmed.to_string());
}
}
None
}
fn resolve_import(
specifier: &str,
from_file: &Path,
workspace: &Path,
alias_root: &Path,
file_index: &HashMap<String, PathBuf>,
candidate_exts_order: &[&str],
) -> Option<PathBuf> {
let base_path: PathBuf = if specifier.starts_with("./") || specifier.starts_with("../") {
from_file.parent()?.join(specifier)
} else if let Some(rest) = specifier.strip_prefix("@/") {
alias_root.join(rest)
} else {
return None;
};
let normalized = normalize_path(&base_path);
let mut candidates: Vec<PathBuf> = Vec::new();
for ext in candidate_exts_order {
candidates.push(normalized.with_extension(ext));
}
for ext in candidate_exts_order {
candidates.push(normalized.join(format!("index.{ext}")));
}
for candidate in &candidates {
if let Some(mod_path) = file_to_mod_path(workspace, candidate)
&& file_index.contains_key(&mod_path)
{
return file_index.get(&mod_path).cloned();
}
}
None
}
fn normalize_path(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
pub struct JavascriptPlugin;
const JS_EXTS: &[&str] = &["js", "jsx", "mjs", "cjs"];
impl LanguagePlugin for JavascriptPlugin {
fn name(&self) -> &str {
"javascript"
}
fn detect(&self, workspace: &Path, _input: &PluginInput) -> bool {
detect_with_marker(workspace, "package.json")
}
fn levels(&self) -> Vec<Level> {
vec![ecmascript_level("files")]
}
fn analyze(&self, workspace: &Path, _level: &str, input: &PluginInput) -> Result<Graph> {
analyze_ecmascript(
workspace,
JS_EXTS,
|ext| match ext {
"js" | "jsx" | "mjs" => Some(tree_sitter_javascript::LANGUAGE.into()),
_ => None,
},
&["js", "jsx", "mjs", "cjs"],
input.ignore_tests,
)
}
fn is_test_path(&self, rel_path: &str) -> bool {
ecmascript_is_test_path(rel_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn file_to_mod_path_strips_ext_and_collapses_index() {
let ws = Path::new("/proj");
assert_eq!(
file_to_mod_path(ws, Path::new("/proj/src/lib/utils.ts")).as_deref(),
Some("src/lib/utils")
);
assert_eq!(
file_to_mod_path(ws, Path::new("/proj/src/lib/index.ts")).as_deref(),
Some("src/lib")
);
}
#[test]
fn external_package_extracts_top_level_and_scope() {
assert_eq!(external_package("react").as_deref(), Some("react"));
assert_eq!(external_package("lodash/fp").as_deref(), Some("lodash"));
assert_eq!(
external_package("@scope/pkg/sub").as_deref(),
Some("@scope/pkg")
);
assert_eq!(external_package("./local"), None);
assert_eq!(external_package("@/aliased"), None);
}
#[test]
fn resolve_import_external_package_is_skipped() {
let got = resolve_import(
"react",
Path::new("/proj/src/a.ts"),
Path::new("/proj"),
Path::new("/proj/src"),
&HashMap::new(),
&["ts", "tsx", "js", "jsx"],
);
assert_eq!(got, None, "bare package specifiers are not local imports");
}
#[test]
fn find_source_root_prefers_existing_src_dir() {
let tmp = TempDir::new().unwrap();
assert_eq!(find_source_root(tmp.path()), tmp.path());
fs::create_dir(tmp.path().join("src")).unwrap();
assert_eq!(find_source_root(tmp.path()), tmp.path().join("src"));
}
fn write_file(dir: &Path, rel: &str, contents: &str) {
let p = dir.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, contents).unwrap();
}
#[test]
fn analyze_builds_file_graph_with_imports_and_externals() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_file(
root,
"src/a.ts",
"import { greet } from \"./b\";\n\
import React from \"react\";\n\
export function helper() { return greet(); }\n",
);
write_file(
root,
"src/b.ts",
"export function greet() { return \"hi\"; }\n",
);
let graph = analyze_ecmascript(
root,
&["ts"],
|ext| match ext {
"ts" => Some(tree_sitter_javascript::LANGUAGE.into()),
_ => None,
},
&["ts", "tsx", "js", "jsx"],
false,
)
.expect("analyze_ecmascript should succeed");
let a_id = root.join("src/a.ts").to_string_lossy().into_owned();
let b_id = root.join("src/b.ts").to_string_lossy().into_owned();
assert!(
graph.nodes.iter().any(|n| n.id == a_id && n.kind == "file"),
"a.ts node present"
);
assert!(
graph
.edges
.iter()
.any(|e| e.source == a_id && e.target == b_id && e.kind == "uses"),
"expected import edge a.ts → b.ts"
);
assert!(
graph
.nodes
.iter()
.any(|n| n.id == "ext:react" && n.kind == "external"),
"external node for react"
);
assert!(
graph
.edges
.iter()
.any(|e| e.source == a_id && e.target == "ext:react"),
"external edge a.ts → react"
);
}
#[test]
fn ecmascript_is_test_path_matches_conventions() {
for p in [
"src/a.test.ts",
"src/a.spec.tsx",
"__tests__/a.js",
"src/__mocks__/fs.js",
"test/helper.ts",
"src/foo_test.js",
] {
assert!(ecmascript_is_test_path(p), "should be a test: {p}");
}
for p in ["src/a.ts", "src/latest.ts", "src/contest.js"] {
assert!(!ecmascript_is_test_path(p), "should not be a test: {p}");
}
}
#[test]
fn ecmascript_level_has_expected_structure() {
let level = ecmascript_level("files");
assert_eq!(level.name, "files");
assert!(level.edge_kinds.contains_key("uses"));
let uses = &level.edge_kinds["uses"];
assert!(uses.flow);
assert!(level.node_attributes.contains_key("loc"));
assert!(level.node_attributes.contains_key("visibility"));
assert!(level.node_attributes.contains_key("external"));
assert!(level.edge_attributes.is_empty());
assert!(level.attribute_groups.is_empty());
}
}