1use anyhow::Result;
9use code_ranker_plugin_api::{
10 attrs::{AttrValue, ValueType},
11 default_cycle_kinds, default_node_kinds,
12 edge::Edge,
13 graph::Graph,
14 level::{AttributeSpec, EdgeKindSpec, Level},
15 node::Node,
16 plugin::{LanguagePlugin, PluginInput},
17};
18use std::collections::{BTreeMap, HashMap};
19use std::path::{Path, PathBuf};
20use walkdir::WalkDir;
21
22pub fn ecmascript_level(name: &str) -> Level {
31 let mut edge_kinds = BTreeMap::new();
32 edge_kinds.insert(
33 "uses".to_string(),
34 EdgeKindSpec {
35 flow: true,
36 label: Some("uses".to_string()),
37 description: Some(
38 "Import dependency \u{2014} this file imports from the other.".to_string(),
39 ),
40 },
41 );
42
43 let mut node_attributes = BTreeMap::new();
44 node_attributes.insert(
45 "path".to_string(),
46 AttributeSpec::new(ValueType::Str, "Path"),
47 );
48 node_attributes.insert(
49 "loc".to_string(),
50 AttributeSpec::new(ValueType::Int, "Lines"),
51 );
52 node_attributes.insert(
53 "visibility".to_string(),
54 AttributeSpec::new(ValueType::Str, "Visibility"),
55 );
56 node_attributes.insert(
57 "external".to_string(),
58 AttributeSpec::new(ValueType::Bool, "External"),
59 );
60
61 Level {
62 name: name.to_string(),
63 edge_kinds,
64 node_attributes,
65 edge_attributes: BTreeMap::new(),
66 attribute_groups: BTreeMap::new(),
67 node_kinds: default_node_kinds(),
68 cycle_kinds: default_cycle_kinds(),
69 grouping: None,
70 }
71}
72
73pub fn detect_with_marker(workspace: &Path, marker: &str) -> bool {
78 workspace.join(marker).exists()
79}
80
81pub fn analyze_ecmascript(
93 workspace: &Path,
94 exts: &[&str],
95 lang_for_ext: impl Fn(&str) -> Option<tree_sitter::Language>,
96 candidate_exts_order: &[&str],
97 ignore_tests: bool,
98) -> Result<Graph> {
99 let source_root = find_source_root(workspace);
100 let alias_root = source_root.clone();
101 let files = collect_files(&source_root, exts, ignore_tests);
102 let file_index = build_file_index(workspace, &files);
103
104 let mut nodes: Vec<Node> = Vec::new();
105 let mut edges: Vec<Edge> = Vec::new();
106 let mut ext_seen: HashMap<String, ()> = HashMap::new();
108 let mut file_ids_seen: HashMap<String, ()> = HashMap::new();
110
111 for abs_path in &files {
112 let ext = abs_path.extension().and_then(|e| e.to_str()).unwrap_or("");
113 let language = match lang_for_ext(ext) {
114 Some(l) => l,
115 None => continue,
116 };
117
118 let source = match std::fs::read(abs_path) {
119 Ok(s) => s,
120 Err(_) => continue,
121 };
122
123 let mut ts_parser = tree_sitter::Parser::new();
124 ts_parser
125 .set_language(&language)
126 .map_err(|e| anyhow::anyhow!("{e}"))?;
127
128 let tree = match ts_parser.parse(&source, None) {
129 Some(t) => t,
130 None => continue,
131 };
132
133 let loc = source.iter().filter(|&&b| b == b'\n').count() as i64 + 1;
134 let file_id = abs_path.to_string_lossy().into_owned();
135
136 if !file_ids_seen.contains_key(&file_id) {
137 file_ids_seen.insert(file_id.clone(), ());
138 let mut attrs = BTreeMap::new();
139 attrs.insert(
140 "visibility".to_string(),
141 AttrValue::Str("public".to_string()),
142 );
143 attrs.insert("loc".to_string(), AttrValue::Int(loc));
144 nodes.push(Node {
145 id: file_id.clone(),
146 kind: "file".to_string(),
147 name: abs_path
148 .file_name()
149 .unwrap_or_default()
150 .to_string_lossy()
151 .into_owned(),
152 parent: None,
153 attrs,
154 });
155 }
156
157 let specifiers = extract_import_specifiers(&tree.root_node(), &source);
158
159 for (spec, line) in &specifiers {
160 if let Some(target) = resolve_import(
161 spec,
162 abs_path,
163 workspace,
164 &alias_root,
165 &file_index,
166 candidate_exts_order,
167 ) {
168 let target_id = target.to_string_lossy().into_owned();
169 if target_id != file_id {
170 edges.push(Edge {
171 source: file_id.clone(),
172 target: target_id,
173 kind: "uses".to_string(),
174 line: Some(*line),
175 attrs: BTreeMap::new(),
176 });
177 }
178 } else if let Some(pkg) = external_package(spec) {
179 let ext_id = format!("ext:{pkg}");
180 if !ext_seen.contains_key(&ext_id) {
181 ext_seen.insert(ext_id.clone(), ());
182 let mut attrs = BTreeMap::new();
183 attrs.insert("external".to_string(), AttrValue::Bool(true));
184 nodes.push(Node {
185 id: ext_id.clone(),
186 kind: "external".to_string(),
187 name: pkg,
188 parent: None,
189 attrs,
190 });
191 }
192 edges.push(Edge {
193 source: file_id.clone(),
194 target: ext_id,
195 kind: "uses".to_string(),
196 line: Some(*line),
197 attrs: BTreeMap::new(),
198 });
199 }
200 }
201 }
202
203 Ok(Graph { nodes, edges })
204}
205
206fn find_source_root(workspace: &Path) -> PathBuf {
211 let src = workspace.join("src");
212 if src.is_dir() {
213 src
214 } else {
215 workspace.to_owned()
216 }
217}
218
219fn collect_files(root: &Path, exts: &[&str], ignore_tests: bool) -> Vec<PathBuf> {
224 WalkDir::new(root)
225 .into_iter()
226 .filter_map(|e| e.ok())
227 .filter(|e| {
228 e.file_type().is_file()
229 && e.path()
230 .extension()
231 .is_some_and(|x| exts.contains(&x.to_str().unwrap_or("")))
232 && !is_skip_path(e.path(), root)
233 && !(ignore_tests && is_test_file(e.path(), root))
234 })
235 .map(|e| e.into_path())
236 .collect()
237}
238
239pub fn ecmascript_is_test_path(rel_path: &str) -> bool {
243 let file = rel_path.rsplit('/').next().unwrap_or(rel_path);
244 let stem = file.split('.').next().unwrap_or(file);
245 rel_path
246 .split('/')
247 .any(|c| matches!(c, "__tests__" | "__mocks__" | "tests" | "test"))
248 || file.contains(".test.")
249 || file.contains(".spec.")
250 || stem.ends_with("_test")
251 || stem.ends_with("_spec")
252}
253
254fn is_test_file(path: &Path, root: &Path) -> bool {
256 path.strip_prefix(root)
257 .ok()
258 .map(|rel| ecmascript_is_test_path(&rel.to_string_lossy().replace('\\', "/")))
259 .unwrap_or(false)
260}
261
262fn is_skip_path(path: &Path, workspace: &Path) -> bool {
263 path.strip_prefix(workspace)
264 .map(|rel| {
265 rel.components().any(|c| {
266 let s = c.as_os_str().to_string_lossy();
267 s == "node_modules"
268 || s == "dist"
269 || s == "target"
270 || s == "build"
271 || s == "out"
272 || s == ".venv"
273 || s == "__pycache__"
274 || s.starts_with('.')
275 || s.ends_with(".gen.ts")
276 || s.ends_with(".config.ts")
277 || s.ends_with(".config.js")
278 || s.ends_with(".min.js")
279 || s.ends_with(".min.ts")
280 || s.ends_with(".umd.js")
281 || s.ends_with(".bundle.js")
282 })
283 })
284 .unwrap_or(false)
285}
286
287fn file_to_mod_path(workspace: &Path, path: &Path) -> Option<String> {
294 let rel = path.strip_prefix(workspace).ok()?;
295 let mut parts: Vec<String> = rel
296 .components()
297 .map(|c| c.as_os_str().to_string_lossy().into_owned())
298 .collect();
299
300 let last = parts.last_mut()?;
301 for ext in &[".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".mts", ".cts"] {
302 if let Some(stem) = last.strip_suffix(ext) {
303 *last = stem.to_string();
304 break;
305 }
306 }
307 if parts.last().map(|s| s == "index").unwrap_or(false) {
308 parts.pop();
309 }
310 if parts.is_empty() {
311 return None;
312 }
313 Some(parts.join("/"))
314}
315
316fn build_file_index(workspace: &Path, files: &[PathBuf]) -> HashMap<String, PathBuf> {
318 files
319 .iter()
320 .filter_map(|p| file_to_mod_path(workspace, p).map(|m| (m, p.clone())))
321 .collect()
322}
323
324pub fn external_package(spec: &str) -> Option<String> {
333 if spec.starts_with("./")
334 || spec.starts_with("../")
335 || spec.starts_with("@/")
336 || spec.is_empty()
337 {
338 return None;
339 }
340 let mut it = spec.split('/');
341 let first = it.next().unwrap_or(spec);
342 if first.starts_with('@') {
343 match it.next() {
344 Some(second) => Some(format!("{first}/{second}")),
345 None => Some(first.to_string()),
346 }
347 } else {
348 Some(first.to_string())
349 }
350}
351
352fn extract_import_specifiers(root: &tree_sitter::Node, source: &[u8]) -> Vec<(String, u32)> {
358 let mut specs = Vec::new();
359 visit_imports(root, source, &mut specs);
360 specs
361}
362
363fn visit_imports<'t>(node: &tree_sitter::Node<'t>, source: &[u8], specs: &mut Vec<(String, u32)>) {
364 let mut cursor = node.walk();
365 let children: Vec<tree_sitter::Node<'t>> = node.children(&mut cursor).collect();
366
367 for child in &children {
368 let line = child.start_position().row as u32 + 1;
369 match child.kind() {
370 "import_statement" => {
372 if let Some(src) = import_source(child, source) {
373 specs.push((src, line));
374 }
375 }
376 "export_statement" => {
378 if let Some(src) = import_source(child, source) {
379 specs.push((src, line));
380 }
381 visit_imports(child, source, specs);
382 }
383 "call_expression" => {
384 if let Some(src) = require_source(child, source) {
385 specs.push((src, line));
386 } else {
387 visit_imports(child, source, specs);
388 }
389 }
390 _ => visit_imports(child, source, specs),
391 }
392 }
393}
394
395fn import_source(node: &tree_sitter::Node, source: &[u8]) -> Option<String> {
397 let mut cursor = node.walk();
398 let children: Vec<_> = node.children(&mut cursor).collect();
399 for child in children.iter().rev() {
400 if child.kind() == "string"
401 && let Ok(raw) = child.utf8_text(source)
402 {
403 let trimmed = raw.trim_matches(|c| c == '\'' || c == '"' || c == '`');
404 return Some(trimmed.to_string());
405 }
406 }
407 None
408}
409
410fn require_source(node: &tree_sitter::Node, source: &[u8]) -> Option<String> {
412 let fn_node = node.child_by_field_name("function")?;
413 let fn_text = fn_node.utf8_text(source).ok()?;
414 if fn_text != "require" {
415 return None;
416 }
417 let args = node.child_by_field_name("arguments")?;
418 let mut cursor = args.walk();
419 for child in args.children(&mut cursor) {
420 if child.kind() == "string"
421 && let Ok(raw) = child.utf8_text(source)
422 {
423 let trimmed = raw.trim_matches(|c| c == '\'' || c == '"' || c == '`');
424 return Some(trimmed.to_string());
425 }
426 }
427 None
428}
429
430fn resolve_import(
435 specifier: &str,
436 from_file: &Path,
437 workspace: &Path,
438 alias_root: &Path,
439 file_index: &HashMap<String, PathBuf>,
440 candidate_exts_order: &[&str],
441) -> Option<PathBuf> {
442 let base_path: PathBuf = if specifier.starts_with("./") || specifier.starts_with("../") {
443 from_file.parent()?.join(specifier)
444 } else if let Some(rest) = specifier.strip_prefix("@/") {
445 alias_root.join(rest)
446 } else {
447 return None;
448 };
449
450 let normalized = normalize_path(&base_path);
451
452 let mut candidates: Vec<PathBuf> = Vec::new();
454 for ext in candidate_exts_order {
455 candidates.push(normalized.with_extension(ext));
456 }
457 for ext in candidate_exts_order {
458 candidates.push(normalized.join(format!("index.{ext}")));
459 }
460
461 for candidate in &candidates {
462 if let Some(mod_path) = file_to_mod_path(workspace, candidate)
463 && file_index.contains_key(&mod_path)
464 {
465 return file_index.get(&mod_path).cloned();
466 }
467 }
468 None
469}
470
471fn normalize_path(path: &Path) -> PathBuf {
473 let mut out = PathBuf::new();
474 for comp in path.components() {
475 match comp {
476 std::path::Component::ParentDir => {
477 out.pop();
478 }
479 std::path::Component::CurDir => {}
480 other => out.push(other),
481 }
482 }
483 out
484}
485
486pub struct JavascriptPlugin;
492
493const JS_EXTS: &[&str] = &["js", "jsx", "mjs", "cjs"];
494
495impl LanguagePlugin for JavascriptPlugin {
496 fn name(&self) -> &str {
497 "javascript"
498 }
499
500 fn detect(&self, workspace: &Path, _input: &PluginInput) -> bool {
501 detect_with_marker(workspace, "package.json")
502 }
503
504 fn levels(&self) -> Vec<Level> {
505 vec![ecmascript_level("files")]
506 }
507
508 fn analyze(&self, workspace: &Path, _level: &str, input: &PluginInput) -> Result<Graph> {
509 analyze_ecmascript(
510 workspace,
511 JS_EXTS,
512 |ext| match ext {
513 "js" | "jsx" | "mjs" => Some(tree_sitter_javascript::LANGUAGE.into()),
514 _ => None,
515 },
516 &["js", "jsx", "mjs", "cjs"],
517 input.ignore_tests,
518 )
519 }
520
521 fn is_test_path(&self, rel_path: &str) -> bool {
522 ecmascript_is_test_path(rel_path)
523 }
524}
525
526#[cfg(test)]
531mod tests {
532 use super::*;
533 use std::fs;
534 use tempfile::TempDir;
535
536 #[test]
537 fn file_to_mod_path_strips_ext_and_collapses_index() {
538 let ws = Path::new("/proj");
539 assert_eq!(
540 file_to_mod_path(ws, Path::new("/proj/src/lib/utils.ts")).as_deref(),
541 Some("src/lib/utils")
542 );
543 assert_eq!(
544 file_to_mod_path(ws, Path::new("/proj/src/lib/index.ts")).as_deref(),
545 Some("src/lib")
546 );
547 }
548
549 #[test]
550 fn external_package_extracts_top_level_and_scope() {
551 assert_eq!(external_package("react").as_deref(), Some("react"));
552 assert_eq!(external_package("lodash/fp").as_deref(), Some("lodash"));
553 assert_eq!(
554 external_package("@scope/pkg/sub").as_deref(),
555 Some("@scope/pkg")
556 );
557 assert_eq!(external_package("./local"), None);
558 assert_eq!(external_package("@/aliased"), None);
559 }
560
561 #[test]
562 fn resolve_import_external_package_is_skipped() {
563 let got = resolve_import(
564 "react",
565 Path::new("/proj/src/a.ts"),
566 Path::new("/proj"),
567 Path::new("/proj/src"),
568 &HashMap::new(),
569 &["ts", "tsx", "js", "jsx"],
570 );
571 assert_eq!(got, None, "bare package specifiers are not local imports");
572 }
573
574 #[test]
575 fn find_source_root_prefers_existing_src_dir() {
576 let tmp = TempDir::new().unwrap();
577 assert_eq!(find_source_root(tmp.path()), tmp.path());
578 fs::create_dir(tmp.path().join("src")).unwrap();
579 assert_eq!(find_source_root(tmp.path()), tmp.path().join("src"));
580 }
581
582 fn write_file(dir: &Path, rel: &str, contents: &str) {
583 let p = dir.join(rel);
584 fs::create_dir_all(p.parent().unwrap()).unwrap();
585 fs::write(p, contents).unwrap();
586 }
587
588 #[test]
589 fn analyze_builds_file_graph_with_imports_and_externals() {
590 let tmp = TempDir::new().unwrap();
591 let root = tmp.path();
592 write_file(
593 root,
594 "src/a.ts",
595 "import { greet } from \"./b\";\n\
596 import React from \"react\";\n\
597 export function helper() { return greet(); }\n",
598 );
599 write_file(
600 root,
601 "src/b.ts",
602 "export function greet() { return \"hi\"; }\n",
603 );
604
605 let graph = analyze_ecmascript(
608 root,
609 &["ts"],
610 |ext| match ext {
611 "ts" => Some(tree_sitter_javascript::LANGUAGE.into()),
612 _ => None,
613 },
614 &["ts", "tsx", "js", "jsx"],
615 false,
616 )
617 .expect("analyze_ecmascript should succeed");
618
619 let a_id = root.join("src/a.ts").to_string_lossy().into_owned();
620 let b_id = root.join("src/b.ts").to_string_lossy().into_owned();
621
622 assert!(
623 graph.nodes.iter().any(|n| n.id == a_id && n.kind == "file"),
624 "a.ts node present"
625 );
626 assert!(
627 graph
628 .edges
629 .iter()
630 .any(|e| e.source == a_id && e.target == b_id && e.kind == "uses"),
631 "expected import edge a.ts → b.ts"
632 );
633 assert!(
634 graph
635 .nodes
636 .iter()
637 .any(|n| n.id == "ext:react" && n.kind == "external"),
638 "external node for react"
639 );
640 assert!(
641 graph
642 .edges
643 .iter()
644 .any(|e| e.source == a_id && e.target == "ext:react"),
645 "external edge a.ts → react"
646 );
647 }
648
649 #[test]
650 fn ecmascript_is_test_path_matches_conventions() {
651 for p in [
652 "src/a.test.ts",
653 "src/a.spec.tsx",
654 "__tests__/a.js",
655 "src/__mocks__/fs.js",
656 "test/helper.ts",
657 "src/foo_test.js",
658 ] {
659 assert!(ecmascript_is_test_path(p), "should be a test: {p}");
660 }
661 for p in ["src/a.ts", "src/latest.ts", "src/contest.js"] {
662 assert!(!ecmascript_is_test_path(p), "should not be a test: {p}");
663 }
664 }
665
666 #[test]
667 fn ecmascript_level_has_expected_structure() {
668 let level = ecmascript_level("files");
669 assert_eq!(level.name, "files");
670 assert!(level.edge_kinds.contains_key("uses"));
671 let uses = &level.edge_kinds["uses"];
672 assert!(uses.flow);
673 assert!(level.node_attributes.contains_key("loc"));
674 assert!(level.node_attributes.contains_key("visibility"));
675 assert!(level.node_attributes.contains_key("external"));
676 assert!(level.edge_attributes.is_empty());
677 assert!(level.attribute_groups.is_empty());
678 }
679}