Skip to main content

semantic/analysis/
analysis_classify.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Change classification engine — determines whether a file modification is
3//! logic, formatting, imports-only, comments-only, or mixed.
4
5use std::path::Path;
6
7use objects::object::{ChangeImportance, ModificationKind};
8
9use super::analysis_similarity::{SimilarityMethod, compute_similarity};
10use crate::parser::{Language, ParsedFile, walk_non_comment_leaves};
11
12/// Classification result: kind, importance, and confidence.
13pub type ClassificationResult = (ModificationKind, ChangeImportance, f64);
14
15/// Classify what kind of modification happened to a file and its review importance.
16///
17/// This is the core engine behind "147 files changed → 11 things worth reviewing":
18/// it separates noise (formatting, imports, comments) from signal (logic changes).
19///
20/// Returns (kind, importance, confidence) where confidence is 0.0–1.0.
21/// AST-backed classification gets high confidence (0.9+), token-fallback gets medium (0.6–0.7).
22pub fn classify_modification(
23    path: &Path,
24    old_content: &str,
25    new_content: &str,
26) -> (ModificationKind, ChangeImportance) {
27    let (kind, importance, _confidence) =
28        classify_modification_with_confidence(path, old_content, new_content);
29    (kind, importance)
30}
31
32/// Like `classify_modification` but also returns a confidence score.
33pub fn classify_modification_with_confidence(
34    path: &Path,
35    old_content: &str,
36    new_content: &str,
37) -> ClassificationResult {
38    let token_sim = classify_common_prefix(old_content, new_content);
39    if let Some(result) = token_sim.result {
40        return result;
41    }
42
43    let language = Language::from_path(path);
44
45    // Try AST-based classification. Falls back to token-level if parsing fails.
46    let old_parsed = ParsedFile::parse(old_content, language);
47    let new_parsed = ParsedFile::parse(new_content, language);
48
49    match (old_parsed.as_ref(), new_parsed.as_ref()) {
50        (Some(old_ast), Some(new_ast)) => {
51            classify_with_parsed(old_content, new_content, old_ast, new_ast)
52        }
53        _ => classify_without_ast(old_content, new_content, token_sim.value),
54    }
55}
56
57/// Classify a modification using ASTs already parsed by the caller.
58pub(crate) fn classify_with_parsed(
59    old_content: &str,
60    new_content: &str,
61    old_ast: &ParsedFile,
62    new_ast: &ParsedFile,
63) -> ClassificationResult {
64    let token_sim = classify_common_prefix(old_content, new_content);
65    if let Some(result) = token_sim.result {
66        return result;
67    }
68
69    classify_with_ast(old_content, new_content, old_ast, new_ast)
70}
71
72struct TokenSimilarityCheck {
73    value: f64,
74    result: Option<ClassificationResult>,
75}
76
77fn classify_common_prefix(old_content: &str, new_content: &str) -> TokenSimilarityCheck {
78    // Identical content should not reach here, but handle it gracefully.
79    if old_content == new_content {
80        return TokenSimilarityCheck {
81            value: 1.0,
82            result: Some((
83                ModificationKind::WhitespaceOnly,
84                ChangeImportance::Noise,
85                1.0,
86            )),
87        };
88    }
89
90    // --- Check 1: Token-identical means formatting/whitespace only ---
91    let token_sim = compute_similarity(old_content, new_content, SimilarityMethod::Tokens);
92    if token_sim >= 1.0 {
93        // Tokens are identical but raw text differs → pure formatting/whitespace.
94        // High confidence: token identity is a strong signal.
95        return TokenSimilarityCheck {
96            value: token_sim,
97            result: Some((
98                ModificationKind::FormattingOnly,
99                ChangeImportance::Noise,
100                0.95,
101            )),
102        };
103    }
104
105    TokenSimilarityCheck {
106        value: token_sim,
107        result: None,
108    }
109}
110
111/// AST-backed classification — the most accurate path.
112fn classify_with_ast(
113    old_content: &str,
114    new_content: &str,
115    old_ast: &ParsedFile,
116    new_ast: &ParsedFile,
117) -> ClassificationResult {
118    let old_funcs = old_ast.extract_functions();
119    let new_funcs = new_ast.extract_functions();
120    let old_imports = old_ast.extract_imports();
121    let new_imports = new_ast.extract_imports();
122
123    let funcs_identical = are_functions_identical(&old_funcs, &new_funcs);
124    let imports_identical = old_imports.len() == new_imports.len()
125        && old_imports
126            .iter()
127            .zip(new_imports.iter())
128            .all(|(a, b)| a.raw == b.raw);
129
130    // Check comments-only: strip comments from both and compare.
131    let old_stripped = strip_comments(old_ast);
132    let new_stripped = strip_comments(new_ast);
133    let non_comment_identical = old_stripped == new_stripped;
134
135    if non_comment_identical {
136        return (ModificationKind::CommentsOnly, ChangeImportance::Low, 0.92);
137    }
138
139    if funcs_identical && !imports_identical {
140        // Functions haven't changed, only imports differ.
141        // Double-check that non-import, non-function code is also identical.
142        let old_body = strip_imports_and_functions(old_ast);
143        let new_body = strip_imports_and_functions(new_ast);
144        if old_body == new_body {
145            return (ModificationKind::ImportsOnly, ChangeImportance::Low, 0.93);
146        }
147    }
148
149    // Check if token-equivalent (formatting only) but AST was parseable.
150    let token_sim = compute_similarity(old_content, new_content, SimilarityMethod::Tokens);
151    if token_sim >= 1.0 {
152        return (
153            ModificationKind::FormattingOnly,
154            ChangeImportance::Noise,
155            0.97,
156        );
157    }
158
159    // If functions changed but formatting also changed, it's mixed.
160    // Heuristic: compute line similarity to detect formatting noise alongside logic.
161    let line_sim = compute_similarity(old_content, new_content, SimilarityMethod::Lines);
162    if token_sim > 0.9 && line_sim < 0.7 {
163        // High token overlap but low line overlap → mostly formatting with some logic.
164        return (ModificationKind::Mixed, ChangeImportance::Medium, 0.75);
165    }
166
167    // Default: real logic change. AST-backed so reasonably confident.
168    (ModificationKind::Logic, ChangeImportance::High, 0.85)
169}
170
171/// Token-level fallback when tree-sitter parsing fails (lower confidence).
172fn classify_without_ast(
173    old_content: &str,
174    new_content: &str,
175    token_sim: f64,
176) -> ClassificationResult {
177    if token_sim >= 1.0 {
178        return (
179            ModificationKind::FormattingOnly,
180            ChangeImportance::Noise,
181            0.9,
182        );
183    }
184
185    let line_sim = compute_similarity(old_content, new_content, SimilarityMethod::Lines);
186
187    // High token similarity + low line similarity → mostly formatting.
188    if token_sim > 0.95 && line_sim < 0.8 {
189        return (
190            ModificationKind::FormattingOnly,
191            ChangeImportance::Noise,
192            0.7,
193        );
194    }
195
196    if token_sim > 0.9 {
197        return (ModificationKind::Mixed, ChangeImportance::Medium, 0.6);
198    }
199
200    // Token-level fallback — lower confidence since we can't parse the AST.
201    (ModificationKind::Logic, ChangeImportance::High, 0.5)
202}
203
204/// Compare function lists for identity (same names, same content).
205fn are_functions_identical(
206    old_funcs: &[crate::parser::FunctionDef],
207    new_funcs: &[crate::parser::FunctionDef],
208) -> bool {
209    if old_funcs.len() != new_funcs.len() {
210        return false;
211    }
212    // Sort by name for stable comparison.
213    let mut old_sorted: Vec<_> = old_funcs.iter().collect();
214    let mut new_sorted: Vec<_> = new_funcs.iter().collect();
215    old_sorted.sort_by_key(|f| &f.name);
216    new_sorted.sort_by_key(|f| &f.name);
217
218    old_sorted
219        .iter()
220        .zip(new_sorted.iter())
221        .all(|(a, b)| a.name == b.name && a.content == b.content)
222}
223
224/// Walk the AST and collect text of all non-comment nodes.
225fn strip_comments(parsed: &ParsedFile) -> String {
226    let mut result = String::new();
227    collect_non_comment_text(parsed.root_node(), &parsed.source, &mut result);
228    result
229}
230
231fn collect_non_comment_text(node: tree_sitter::Node<'_>, source: &str, out: &mut String) {
232    walk_non_comment_leaves(node, |leaf| {
233        out.push_str(&source[leaf.byte_range()]);
234        out.push(' ');
235    });
236}
237
238/// Strip imports and function bodies, return remaining "scaffold" text.
239fn strip_imports_and_functions(parsed: &ParsedFile) -> String {
240    let mut result = String::new();
241    let root = parsed.root_node();
242    for i in 0..root.child_count() {
243        if let Some(child) = root.child(i as u32) {
244            let kind = child.kind();
245            // Skip imports.
246            if matches!(
247                kind,
248                "use_declaration"
249                    | "extern_crate_declaration"
250                    | "import_statement"
251                    | "import_from_statement"
252                    | "import_declaration"
253            ) {
254                continue;
255            }
256            // Skip function definitions.
257            if ParsedFile::is_function_kind(kind, parsed.language) {
258                continue;
259            }
260            result.push_str(&parsed.source[child.byte_range()]);
261            result.push('\n');
262        }
263    }
264    result
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_whitespace_only() {
273        let old = "fn foo() {\n    bar();\n}\n";
274        let new = "fn foo() {\n        bar();\n}\n";
275        let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
276        assert_eq!(kind, ModificationKind::FormattingOnly);
277        assert_eq!(importance, ChangeImportance::Noise);
278    }
279
280    #[test]
281    fn test_logic_change() {
282        let old = "fn foo() -> i32 {\n    42\n}\n";
283        let new = "fn foo() -> i32 {\n    43\n}\n";
284        let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
285        assert_eq!(kind, ModificationKind::Logic);
286        assert_eq!(importance, ChangeImportance::High);
287    }
288
289    #[test]
290    fn test_comments_only() {
291        let old = "// old comment\nfn foo() {\n    bar();\n}\n";
292        let new = "// new comment\nfn foo() {\n    bar();\n}\n";
293        let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
294        assert_eq!(kind, ModificationKind::CommentsOnly);
295        assert_eq!(importance, ChangeImportance::Low);
296    }
297
298    #[test]
299    fn test_imports_only() {
300        let old = "use std::io;\n\nfn foo() {\n    bar();\n}\n";
301        let new = "use std::io;\nuse std::fs;\n\nfn foo() {\n    bar();\n}\n";
302        let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
303        assert_eq!(kind, ModificationKind::ImportsOnly);
304        assert_eq!(importance, ChangeImportance::Low);
305    }
306
307    #[test]
308    fn test_parse_error_fallback() {
309        // Unknown language — falls back to token-level classification.
310        let old = "some content here\n";
311        let new = "some content here\nwith additions\n";
312        let (kind, importance) = classify_modification(Path::new("test.xyz"), old, new);
313        // Should classify as Logic since tokens differ and we can't parse.
314        assert_eq!(kind, ModificationKind::Logic);
315        assert_eq!(importance, ChangeImportance::High);
316    }
317
318    #[test]
319    fn test_formatting_only_unknown_lang() {
320        // Token-identical but line-different on unknown language.
321        let old = "foo bar baz\n";
322        let new = "foo  bar  baz\n";
323        let (kind, importance) = classify_modification(Path::new("test.xyz"), old, new);
324        assert_eq!(kind, ModificationKind::FormattingOnly);
325        assert_eq!(importance, ChangeImportance::Noise);
326    }
327
328    #[test]
329    fn test_classify_with_parsed_matches_direct_classifier() {
330        let fixtures = [
331            (
332                "formatting",
333                "fn compute() -> i32 { 1 }\n",
334                "fn compute() -> i32 {\n    1\n}\n",
335            ),
336            (
337                "comments",
338                "// before\nfn compute() -> i32 { 1 }\n",
339                "// after\nfn compute() -> i32 { 1 }\n",
340            ),
341            (
342                "imports",
343                "use std::io;\n\nfn compute() -> i32 {\n    1\n}\n",
344                "use std::io;\nuse std::fs;\n\nfn compute() -> i32 {\n    1\n}\n",
345            ),
346            (
347                "logic",
348                "fn compute(input: i32) -> i32 { input + 1 }\n",
349                "fn compute(input: i32) -> i32 { input * 2 }\n",
350            ),
351        ];
352
353        for (name, old, new) in fixtures {
354            let old_ast = ParsedFile::parse(old, Language::Rust).expect("old Rust should parse");
355            let new_ast = ParsedFile::parse(new, Language::Rust).expect("new Rust should parse");
356            let direct = classify_modification_with_confidence(Path::new("test.rs"), old, new);
357            let cached = classify_with_parsed(old, new, &old_ast, &new_ast);
358
359            assert_eq!(cached, direct, "classification drift for {name}");
360        }
361    }
362}