Skip to main content

code_ranker_plugin_javascript/
lib.rs

1//! JavaScript language plugin for Code Ranker.
2//!
3//! A thin adapter over the shared, grammar-agnostic engine in
4//! `code-ranker-ecmascript-core`: it binds the `tree-sitter-javascript` grammar
5//! to `.js` / `.jsx` / `.mjs` / `.cjs` and detects projects by `package.json`.
6//! It depends on the shared core as a peer — never on the TypeScript plugin (and
7//! vice-versa).
8
9use anyhow::Result;
10use code_ranker_ecmascript_core::{
11    analyze_ecmascript, annotate_ecmascript_metrics, ecmascript_is_test_path, ecmascript_level,
12};
13use code_ranker_plugin_api::{
14    graph::Graph,
15    level::Level,
16    plugin::{LanguagePlugin, PluginInput, detect_with_marker},
17};
18use std::path::Path;
19
20/// The JavaScript language plugin (handles .js / .jsx / .mjs / .cjs).
21pub struct JavascriptPlugin;
22
23const JS_EXTS: &[&str] = &["js", "jsx", "mjs", "cjs"];
24
25impl LanguagePlugin for JavascriptPlugin {
26    fn name(&self) -> &str {
27        "javascript"
28    }
29
30    fn detect(&self, workspace: &Path, _input: &PluginInput) -> bool {
31        detect_with_marker(workspace, "package.json")
32    }
33
34    fn levels(&self) -> Vec<Level> {
35        vec![ecmascript_level("files")]
36    }
37
38    fn analyze(&self, workspace: &Path, _level: &str, input: &PluginInput) -> Result<Graph> {
39        analyze_ecmascript(
40            workspace,
41            JS_EXTS,
42            |ext| match ext {
43                "js" | "jsx" | "mjs" => Some(tree_sitter_javascript::LANGUAGE.into()),
44                _ => None,
45            },
46            &["js", "jsx", "mjs", "cjs"],
47            input.ignore_tests,
48        )
49    }
50
51    fn metrics(&self, graph: &mut Graph) -> usize {
52        annotate_ecmascript_metrics(graph, |ext| match ext {
53            "js" | "jsx" | "mjs" | "cjs" => Some((tree_sitter_javascript::LANGUAGE.into(), false)),
54            _ => None,
55        })
56    }
57
58    fn is_test_path(&self, rel_path: &str) -> bool {
59        ecmascript_is_test_path(rel_path)
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use code_ranker_test_support::{edge_count_from, has_node, write_file};
67    use std::fs;
68    use tempfile::TempDir;
69
70    #[test]
71    fn plugin_name_is_javascript() {
72        assert_eq!(JavascriptPlugin.name(), "javascript");
73    }
74
75    #[test]
76    fn detect_requires_package_json() {
77        let tmp = TempDir::new().unwrap();
78        let input = PluginInput::default();
79        assert!(!JavascriptPlugin.detect(tmp.path(), &input));
80        fs::write(tmp.path().join("package.json"), "{}").unwrap();
81        assert!(JavascriptPlugin.detect(tmp.path(), &input));
82    }
83
84    #[test]
85    fn levels_returns_single_files_level() {
86        let levels = JavascriptPlugin.levels();
87        assert_eq!(levels.len(), 1);
88        assert_eq!(levels[0].name, "files");
89        assert!(levels[0].edge_kinds.contains_key("uses"));
90    }
91
92    #[test]
93    fn analyze_builds_js_graph_with_imports_and_externals() {
94        let tmp = TempDir::new().unwrap();
95        let root = tmp.path();
96        write_file(
97            root,
98            "src/a.js",
99            "import { greet } from \"./b\";\n\
100             import React from \"react\";\n\
101             export function helper() { return greet(); }\n",
102        );
103        write_file(root, "src/b.js", "export function greet() { return 1; }\n");
104
105        let graph = JavascriptPlugin
106            .analyze(root, "files", &PluginInput::default())
107            .expect("analyze should succeed");
108
109        let a_id = root.join("src/a.js").to_string_lossy().into_owned();
110        let b_id = root.join("src/b.js").to_string_lossy().into_owned();
111        assert!(has_node(&graph, &a_id), "a.js file node present");
112        assert!(
113            graph
114                .edges
115                .iter()
116                .any(|e| e.source == a_id && e.target == b_id && e.kind == "uses"),
117            "import edge a.js → b.js"
118        );
119        assert!(has_node(&graph, "ext:react"), "external node for react");
120        assert_eq!(edge_count_from(&graph, &a_id, "uses"), 2, "./b + react");
121    }
122
123    #[test]
124    fn metrics_annotates_file_nodes() {
125        let tmp = TempDir::new().unwrap();
126        let root = tmp.path();
127        write_file(
128            root,
129            "src/a.js",
130            "export function f(x) { if (x > 0) { return 1; } return 2; }\n",
131        );
132        let mut graph = JavascriptPlugin
133            .analyze(root, "files", &PluginInput::default())
134            .expect("analyze should succeed");
135        let annotated = JavascriptPlugin.metrics(&mut graph);
136        assert_eq!(annotated, 1, "the single .js file node is annotated");
137
138        let a_id = root.join("src/a.js").to_string_lossy().into_owned();
139        let node = graph.nodes.iter().find(|n| n.id == a_id).unwrap();
140        // a function with an `if` and two `return`s has real complexity.
141        assert!(
142            node.attrs.contains_key("cyclomatic"),
143            "cyclomatic written onto the file node"
144        );
145    }
146
147    #[test]
148    fn cjs_is_not_detected_as_test() {
149        // `.cjs` files are walked but the JS grammar maps them to no node;
150        // is_test_path follows the shared ECMAScript convention.
151        assert!(JavascriptPlugin.is_test_path("src/a.test.js"));
152        assert!(!JavascriptPlugin.is_test_path("src/a.js"));
153    }
154}