Skip to main content

code_ranker_plugin_javascript/
lib.rs

1//! JavaScript language plugin for Code Ranker.
2//!
3//! Handles `.js`, `.jsx`, `.mjs`, `.cjs` files via tree-sitter-javascript.
4//! Also exposes shared ECMAScript parsing helpers (`ecmascript_level`,
5//! `analyze_ecmascript`, `detect_with_marker`) so the TypeScript plugin can
6//! reuse the walker/resolver without any copy-paste.
7
8use 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
22// ─────────────────────────────────────────────────────────────────────────────
23// Public shared helpers (used by the TypeScript plugin)
24// ─────────────────────────────────────────────────────────────────────────────
25
26/// Build the single "files" [`Level`] that both JS and TS plugins expose.
27///
28/// `name` is the level name (pass `"files"` — kept as a parameter so tests can
29/// verify the returned value without hard-coding a string twice).
30pub 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
73/// Return `true` when `workspace` contains the given marker file.
74///
75/// Signature kept generic so both JS (`"package.json"`) and TS (`"tsconfig.json"`)
76/// can reuse it.
77pub fn detect_with_marker(workspace: &Path, marker: &str) -> bool {
78    workspace.join(marker).exists()
79}
80
81/// Walk `workspace`, parse every file whose extension is in `exts`, and build
82/// an [`api::Graph`] of file + external nodes connected by `"uses"` edges.
83///
84/// `lang_for_ext` maps a file extension to a tree-sitter [`Language`]. Return
85/// `None` to skip the file (the walker already filters by `exts`; returning
86/// `None` here is an escape hatch for finer control).
87///
88/// `candidate_exts_order` controls the order in which candidate extensions are
89/// tried when resolving an extensionless import specifier, e.g. `"./foo"`. The
90/// first match wins. Pass `&["ts", "tsx", "js", "jsx"]` for TypeScript-first
91/// resolution; `&["js", "jsx", "mjs", "cjs"]` for JS-only projects.
92pub 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    // Track external nodes we already emitted to avoid duplicates.
107    let mut ext_seen: HashMap<String, ()> = HashMap::new();
108    // Track file nodes we already emitted.
109    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
206// ─────────────────────────────────────────────────────────────────────────────
207// Source root detection
208// ─────────────────────────────────────────────────────────────────────────────
209
210fn 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
219// ─────────────────────────────────────────────────────────────────────────────
220// File discovery
221// ─────────────────────────────────────────────────────────────────────────────
222
223fn 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
239/// ECMAScript test conventions, shared by the JS and TS plugins: `*.test.*` /
240/// `*.spec.*` files and anything under `__tests__`, `__mocks__`, `tests` or
241/// `test` directories.
242pub 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
254/// Workspace-relative test check used during the walk.
255fn 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
287// ─────────────────────────────────────────────────────────────────────────────
288// Module path helpers
289// ─────────────────────────────────────────────────────────────────────────────
290
291/// `src/lib/utils.ts` → `src/lib/utils`
292/// `src/lib/index.ts` → `src/lib`
293fn 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
316/// Build a map: module_path → abs_path for all collected files.
317fn 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
324// ─────────────────────────────────────────────────────────────────────────────
325// External package name extraction
326// ─────────────────────────────────────────────────────────────────────────────
327
328/// Extract the package name for a bare (non-relative, non-alias) import
329/// specifier: `react` → `react`, `lodash/fp` → `lodash`,
330/// `@scope/pkg/sub` → `@scope/pkg`.
331/// Returns `None` for relative (`./`, `../`) and `@/` alias specifiers.
332pub 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
352// ─────────────────────────────────────────────────────────────────────────────
353// Tree-sitter extraction (import / require specifiers)
354// ─────────────────────────────────────────────────────────────────────────────
355
356/// Each specifier paired with the 1-based line of its import/export/require.
357fn 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 'module' / import { x } from 'module'
371            "import_statement" => {
372                if let Some(src) = import_source(child, source) {
373                    specs.push((src, line));
374                }
375            }
376            // export { x } from 'module'  /  export * from 'module'
377            "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
395/// Extract the module specifier string from an import or re-export statement.
396fn 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
410/// Extract `require("./path")` specifier from a call_expression node.
411fn 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
430// ─────────────────────────────────────────────────────────────────────────────
431// Import resolution
432// ─────────────────────────────────────────────────────────────────────────────
433
434fn 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    // Build candidate list: bare path with each extension, then index.* with each extension.
453    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
471/// Resolve `.` and `..` components without touching the filesystem.
472fn 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
486// ─────────────────────────────────────────────────────────────────────────────
487// Plugin struct
488// ─────────────────────────────────────────────────────────────────────────────
489
490/// The JavaScript language plugin (handles .js / .jsx / .mjs / .cjs).
491pub 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// ─────────────────────────────────────────────────────────────────────────────
527// Tests
528// ─────────────────────────────────────────────────────────────────────────────
529
530#[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        // Use TS extensions so the tree-sitter-javascript parser (used here
606        // via the shared helper) can still parse the TS syntax subset.
607        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}