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    classify_parse_result(
50        old_content,
51        new_content,
52        old_parsed.as_ref(),
53        new_parsed.as_ref(),
54        token_sim.value,
55    )
56}
57
58pub(crate) fn classify_modification_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
111fn classify_parse_result(
112    old_content: &str,
113    new_content: &str,
114    old_parsed: Option<&ParsedFile>,
115    new_parsed: Option<&ParsedFile>,
116    token_sim: f64,
117) -> ClassificationResult {
118    match (old_parsed, new_parsed) {
119        (Some(old_ast), Some(new_ast)) => {
120            classify_with_ast(old_content, new_content, old_ast, new_ast)
121        }
122        _ => {
123            // Parse failed — fall back to token-level heuristics (lower confidence).
124            classify_without_ast(old_content, new_content, token_sim)
125        }
126    }
127}
128
129/// AST-backed classification — the most accurate path.
130fn classify_with_ast(
131    old_content: &str,
132    new_content: &str,
133    old_ast: &ParsedFile,
134    new_ast: &ParsedFile,
135) -> ClassificationResult {
136    let old_funcs = old_ast.extract_functions();
137    let new_funcs = new_ast.extract_functions();
138    let old_imports = old_ast.extract_imports();
139    let new_imports = new_ast.extract_imports();
140
141    let funcs_identical = are_functions_identical(&old_funcs, &new_funcs);
142    let imports_identical = old_imports.len() == new_imports.len()
143        && old_imports
144            .iter()
145            .zip(new_imports.iter())
146            .all(|(a, b)| a.raw == b.raw);
147
148    // Check comments-only: strip comments from both and compare.
149    let old_stripped = strip_comments(old_ast);
150    let new_stripped = strip_comments(new_ast);
151    let non_comment_identical = old_stripped == new_stripped;
152
153    if non_comment_identical {
154        return (ModificationKind::CommentsOnly, ChangeImportance::Low, 0.92);
155    }
156
157    if funcs_identical && !imports_identical {
158        // Functions haven't changed, only imports differ.
159        // Double-check that non-import, non-function code is also identical.
160        let old_body = strip_imports_and_functions(old_ast);
161        let new_body = strip_imports_and_functions(new_ast);
162        if old_body == new_body {
163            return (ModificationKind::ImportsOnly, ChangeImportance::Low, 0.93);
164        }
165    }
166
167    // Check if token-equivalent (formatting only) but AST was parseable.
168    let token_sim = compute_similarity(old_content, new_content, SimilarityMethod::Tokens);
169    if token_sim >= 1.0 {
170        return (
171            ModificationKind::FormattingOnly,
172            ChangeImportance::Noise,
173            0.97,
174        );
175    }
176
177    // If functions changed but formatting also changed, it's mixed.
178    // Heuristic: compute line similarity to detect formatting noise alongside logic.
179    let line_sim = compute_similarity(old_content, new_content, SimilarityMethod::Lines);
180    if token_sim > 0.9 && line_sim < 0.7 {
181        // High token overlap but low line overlap → mostly formatting with some logic.
182        return (ModificationKind::Mixed, ChangeImportance::Medium, 0.75);
183    }
184
185    // Default: real logic change. AST-backed so reasonably confident.
186    (ModificationKind::Logic, ChangeImportance::High, 0.85)
187}
188
189/// Token-level fallback when tree-sitter parsing fails (lower confidence).
190fn classify_without_ast(
191    old_content: &str,
192    new_content: &str,
193    token_sim: f64,
194) -> ClassificationResult {
195    if token_sim >= 1.0 {
196        return (
197            ModificationKind::FormattingOnly,
198            ChangeImportance::Noise,
199            0.9,
200        );
201    }
202
203    let line_sim = compute_similarity(old_content, new_content, SimilarityMethod::Lines);
204
205    // High token similarity + low line similarity → mostly formatting.
206    if token_sim > 0.95 && line_sim < 0.8 {
207        return (
208            ModificationKind::FormattingOnly,
209            ChangeImportance::Noise,
210            0.7,
211        );
212    }
213
214    if token_sim > 0.9 {
215        return (ModificationKind::Mixed, ChangeImportance::Medium, 0.6);
216    }
217
218    // Token-level fallback — lower confidence since we can't parse the AST.
219    (ModificationKind::Logic, ChangeImportance::High, 0.5)
220}
221
222/// Compare function lists for identity (same names, same content).
223fn are_functions_identical(
224    old_funcs: &[crate::parser::FunctionDef],
225    new_funcs: &[crate::parser::FunctionDef],
226) -> bool {
227    if old_funcs.len() != new_funcs.len() {
228        return false;
229    }
230    // Sort by name for stable comparison.
231    let mut old_sorted: Vec<_> = old_funcs.iter().collect();
232    let mut new_sorted: Vec<_> = new_funcs.iter().collect();
233    old_sorted.sort_by_key(|f| &f.name);
234    new_sorted.sort_by_key(|f| &f.name);
235
236    old_sorted
237        .iter()
238        .zip(new_sorted.iter())
239        .all(|(a, b)| a.name == b.name && a.content == b.content)
240}
241
242/// Walk the AST and collect text of all non-comment nodes.
243fn strip_comments(parsed: &ParsedFile) -> String {
244    let mut result = String::new();
245    collect_non_comment_text(parsed.root_node(), &parsed.source, &mut result);
246    result
247}
248
249fn collect_non_comment_text(node: tree_sitter::Node<'_>, source: &str, out: &mut String) {
250    walk_non_comment_leaves(node, |leaf| {
251        out.push_str(&source[leaf.byte_range()]);
252        out.push(' ');
253    });
254}
255
256/// Strip imports and function bodies, return remaining "scaffold" text.
257fn strip_imports_and_functions(parsed: &ParsedFile) -> String {
258    let mut result = String::new();
259    let root = parsed.root_node();
260    for i in 0..root.child_count() {
261        if let Some(child) = root.child(i as u32) {
262            let kind = child.kind();
263            // Skip imports.
264            if matches!(
265                kind,
266                "use_declaration"
267                    | "extern_crate_declaration"
268                    | "import_statement"
269                    | "import_from_statement"
270                    | "import_declaration"
271            ) {
272                continue;
273            }
274            // Skip function definitions.
275            if ParsedFile::is_function_kind(kind, parsed.language) {
276                continue;
277            }
278            result.push_str(&parsed.source[child.byte_range()]);
279            result.push('\n');
280        }
281    }
282    result
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_whitespace_only() {
291        let old = "fn foo() {\n    bar();\n}\n";
292        let new = "fn foo() {\n        bar();\n}\n";
293        let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
294        assert_eq!(kind, ModificationKind::FormattingOnly);
295        assert_eq!(importance, ChangeImportance::Noise);
296    }
297
298    #[test]
299    fn test_logic_change() {
300        let old = "fn foo() -> i32 {\n    42\n}\n";
301        let new = "fn foo() -> i32 {\n    43\n}\n";
302        let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
303        assert_eq!(kind, ModificationKind::Logic);
304        assert_eq!(importance, ChangeImportance::High);
305    }
306
307    #[test]
308    fn test_comments_only() {
309        let old = "// old comment\nfn foo() {\n    bar();\n}\n";
310        let new = "// new comment\nfn foo() {\n    bar();\n}\n";
311        let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
312        assert_eq!(kind, ModificationKind::CommentsOnly);
313        assert_eq!(importance, ChangeImportance::Low);
314    }
315
316    #[test]
317    fn test_imports_only() {
318        let old = "use std::io;\n\nfn foo() {\n    bar();\n}\n";
319        let new = "use std::io;\nuse std::fs;\n\nfn foo() {\n    bar();\n}\n";
320        let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
321        assert_eq!(kind, ModificationKind::ImportsOnly);
322        assert_eq!(importance, ChangeImportance::Low);
323    }
324
325    #[test]
326    fn test_parse_error_fallback() {
327        // Unknown language — falls back to token-level classification.
328        let old = "some content here\n";
329        let new = "some content here\nwith additions\n";
330        let (kind, importance) = classify_modification(Path::new("test.xyz"), old, new);
331        // Should classify as Logic since tokens differ and we can't parse.
332        assert_eq!(kind, ModificationKind::Logic);
333        assert_eq!(importance, ChangeImportance::High);
334    }
335
336    #[test]
337    fn test_formatting_only_unknown_lang() {
338        // Token-identical but line-different on unknown language.
339        let old = "foo bar baz\n";
340        let new = "foo  bar  baz\n";
341        let (kind, importance) = classify_modification(Path::new("test.xyz"), old, new);
342        assert_eq!(kind, ModificationKind::FormattingOnly);
343        assert_eq!(importance, ChangeImportance::Noise);
344    }
345
346    #[test]
347    fn test_classify_with_parsed_matches_direct_classifier() {
348        let old = "use std::io;\n\nfn compute() -> i32 {\n    1\n}\n";
349        let new = "use std::io;\nuse std::fs;\n\nfn compute() -> i32 {\n    1\n}\n";
350        let old_ast = ParsedFile::parse(old, Language::Rust).expect("old Rust should parse");
351        let new_ast = ParsedFile::parse(new, Language::Rust).expect("new Rust should parse");
352
353        let direct = classify_modification_with_confidence(Path::new("test.rs"), old, new);
354        let cached = classify_modification_with_parsed(old, new, &old_ast, &new_ast);
355
356        assert_eq!(cached, direct);
357    }
358}