1use 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
12pub type ClassificationResult = (ModificationKind, ChangeImportance, f64);
14
15pub 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
32pub 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 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 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 let token_sim = compute_similarity(old_content, new_content, SimilarityMethod::Tokens);
92 if token_sim >= 1.0 {
93 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 classify_without_ast(old_content, new_content, token_sim)
125 }
126 }
127}
128
129fn 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 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 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 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 let line_sim = compute_similarity(old_content, new_content, SimilarityMethod::Lines);
180 if token_sim > 0.9 && line_sim < 0.7 {
181 return (ModificationKind::Mixed, ChangeImportance::Medium, 0.75);
183 }
184
185 (ModificationKind::Logic, ChangeImportance::High, 0.85)
187}
188
189fn 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 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 (ModificationKind::Logic, ChangeImportance::High, 0.5)
220}
221
222fn 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 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
242fn 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
256fn 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 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 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 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 assert_eq!(kind, ModificationKind::Logic);
333 assert_eq!(importance, ChangeImportance::High);
334 }
335
336 #[test]
337 fn test_formatting_only_unknown_lang() {
338 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}