Skip to main content

code_split_complexity/
lib.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::Result;
5use code_split_core::{Complexity, GraphBuilder, Halstead, Loc, Maintainability, NodeKind};
6use rust_code_analysis::{
7    FuncSpace, JavascriptParser, ParserTrait, PythonParser, RustParser, SpaceKind, TsxParser,
8    TypescriptParser, metrics,
9};
10use walkdir::WalkDir;
11
12/// Walk all source files under `root` whose extension is in `extensions`,
13/// compute complexity metrics via rust-code-analysis, and annotate
14/// `Fn`, `Method`, and `File` nodes in the graph.
15/// Returns the number of nodes annotated.
16pub fn analyze(root: &Path, builder: &mut GraphBuilder) -> Result<usize> {
17    analyze_extensions(root, builder, &["rs"])
18}
19
20/// Same as `analyze` but for Python source files.
21pub fn analyze_python(root: &Path, builder: &mut GraphBuilder) -> Result<usize> {
22    analyze_extensions(root, builder, &["py"])
23}
24
25/// Same as `analyze` but for JavaScript / TypeScript source files.
26pub fn analyze_js(root: &Path, builder: &mut GraphBuilder) -> Result<usize> {
27    analyze_extensions(root, builder, &["js", "jsx", "ts", "tsx"])
28}
29
30fn analyze_extensions(
31    root: &Path,
32    builder: &mut GraphBuilder,
33    extensions: &[&str],
34) -> Result<usize> {
35    let mut file_index: HashMap<String, usize> = HashMap::new();
36    let mut fn_index: HashMap<(String, String), usize> = HashMap::new();
37    // Fallback: match by (file_path, start_line) for languages where names are "<anonymous>"
38    let mut fn_line_index: HashMap<(String, usize), usize> = HashMap::new();
39
40    for (i, node) in builder.nodes().iter().enumerate() {
41        match node.kind {
42            // Module nodes that represent a file (line == None) share the same
43            // canonical path as the file itself; inline modules (line.is_some()) share
44            // the enclosing file's path and must not receive file-level metrics.
45            NodeKind::File => {
46                file_index.insert(node.path.clone(), i);
47            }
48            NodeKind::Module if node.line.is_none() => {
49                file_index.entry(node.path.clone()).or_insert(i);
50            }
51            NodeKind::Fn | NodeKind::Method => {
52                fn_index.insert((node.path.clone(), node.name.clone()), i);
53                if let Some(line) = node.line {
54                    fn_line_index.insert((node.path.clone(), line as usize), i);
55                }
56            }
57            _ => {}
58        }
59    }
60
61    let mut annotated = 0usize;
62
63    for entry in WalkDir::new(root)
64        .into_iter()
65        .filter_map(|e| e.ok())
66        .filter(|e| {
67            e.file_type().is_file()
68                && e.path()
69                    .extension()
70                    .and_then(|x| x.to_str())
71                    .is_some_and(|x| extensions.contains(&x))
72        })
73    {
74        let path = entry.path();
75        let Ok(src) = std::fs::read(path) else {
76            continue;
77        };
78        let canonical = path.to_string_lossy().into_owned();
79
80        let Some(space) = parse_metrics(path, src) else {
81            continue;
82        };
83
84        if let Some(&idx) = file_index.get(&canonical) {
85            builder.nodes_mut()[idx].complexity = Some(complexity_from(&space));
86            annotated += 1;
87        }
88        annotated += collect_fns(&space, &canonical, builder, &fn_index, &fn_line_index);
89    }
90
91    Ok(annotated)
92}
93
94fn parse_metrics(path: &Path, src: Vec<u8>) -> Option<FuncSpace> {
95    match path.extension().and_then(|e| e.to_str()) {
96        Some("rs") => metrics(&RustParser::new(src, path, None), path),
97        Some("py") => metrics(&PythonParser::new(src, path, None), path),
98        Some("js") | Some("jsx") => metrics(&JavascriptParser::new(src, path, None), path),
99        Some("ts") => metrics(&TypescriptParser::new(src, path, None), path),
100        Some("tsx") => metrics(&TsxParser::new(src, path, None), path),
101        _ => None,
102    }
103}
104
105fn collect_fns(
106    space: &FuncSpace,
107    file: &str,
108    builder: &mut GraphBuilder,
109    fn_index: &HashMap<(String, String), usize>,
110    fn_line_index: &HashMap<(String, usize), usize>,
111) -> usize {
112    let mut count = 0;
113    if matches!(space.kind, SpaceKind::Function) {
114        let name = space.name.as_deref().unwrap_or("?");
115        let bare = name.split("::").last().unwrap_or(name);
116
117        let idx = fn_line_index
118            .get(&(file.to_owned(), space.start_line))
119            .copied()
120            .or_else(|| fn_index.get(&(file.to_owned(), bare.to_owned())).copied());
121
122        if let Some(idx) = idx {
123            builder.nodes_mut()[idx].complexity = Some(complexity_from(space));
124            count += 1;
125        }
126    }
127    for child in &space.spaces {
128        count += collect_fns(child, file, builder, fn_index, fn_line_index);
129    }
130    count
131}
132
133fn complexity_from(s: &FuncSpace) -> Complexity {
134    let m = &s.metrics;
135    let sloc = m.loc.sloc();
136    let vol = m.halstead.volume();
137
138    Complexity {
139        cyclomatic: m.cyclomatic.cyclomatic(),
140        cognitive: m.cognitive.cognitive(),
141        exits: m.nexits.exit(),
142        // fn_args > 0 → args = fn_args; otherwise use closure_args
143        args: if m.nargs.fn_args() > 0.0 {
144            m.nargs.fn_args()
145        } else {
146            m.nargs.closure_args()
147        },
148        functions: m.nom.functions(),
149        closures: m.nom.closures(),
150        coupling: None, // filled later in annotate_hk
151        maintainability: Some(Maintainability {
152            mi: m.mi.mi_original(),
153            mi_sei: m.mi.mi_sei(),
154        }),
155        loc: if sloc > 0.0 {
156            Some(Loc {
157                source: sloc,
158                logical: m.loc.lloc(),
159                comments: m.loc.cloc(),
160                blank: m.loc.blank(),
161            })
162        } else {
163            None
164        },
165        halstead: if vol > 0.0 {
166            Some(Halstead {
167                length: m.halstead.length(),
168                vocabulary: (m.halstead.u_operators() + m.halstead.u_operands()),
169                volume: vol,
170                effort: m.halstead.effort(),
171                time: m.halstead.time(),
172                bugs: m.halstead.bugs(),
173            })
174        } else {
175            None
176        },
177    }
178}