1use std::path::Path;
6
7use objects::object::{ChangeImportance, ModificationKind};
8
9use super::analysis_similarity::{SimilarityMethod, compute_similarity};
10use crate::parser::{Language, ParsedFile};
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 let mut stack = vec![node];
251
252 while let Some(current) = stack.pop() {
253 if is_comment_node(current.kind()) {
254 continue;
255 }
256
257 if current.child_count() == 0 {
258 out.push_str(&source[current.byte_range()]);
259 out.push(' ');
260 continue;
261 }
262
263 let child_count = current.child_count();
264 for index in (0..child_count).rev() {
265 if let Some(child) = current.child(index as u32) {
266 stack.push(child);
267 }
268 }
269 }
270}
271
272fn is_comment_node(kind: &str) -> bool {
273 matches!(
274 kind,
275 "comment" | "line_comment" | "block_comment" | "doc_comment" | "string_comment"
276 )
277}
278
279fn strip_imports_and_functions(parsed: &ParsedFile) -> String {
281 let mut result = String::new();
282 let root = parsed.root_node();
283 for i in 0..root.child_count() {
284 if let Some(child) = root.child(i as u32) {
285 let kind = child.kind();
286 if matches!(
288 kind,
289 "use_declaration"
290 | "extern_crate_declaration"
291 | "import_statement"
292 | "import_from_statement"
293 | "import_declaration"
294 ) {
295 continue;
296 }
297 if ParsedFile::is_function_kind(kind, parsed.language) {
299 continue;
300 }
301 result.push_str(&parsed.source[child.byte_range()]);
302 result.push('\n');
303 }
304 }
305 result
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn test_whitespace_only() {
314 let old = "fn foo() {\n bar();\n}\n";
315 let new = "fn foo() {\n bar();\n}\n";
316 let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
317 assert_eq!(kind, ModificationKind::FormattingOnly);
318 assert_eq!(importance, ChangeImportance::Noise);
319 }
320
321 #[test]
322 fn test_logic_change() {
323 let old = "fn foo() -> i32 {\n 42\n}\n";
324 let new = "fn foo() -> i32 {\n 43\n}\n";
325 let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
326 assert_eq!(kind, ModificationKind::Logic);
327 assert_eq!(importance, ChangeImportance::High);
328 }
329
330 #[test]
331 fn test_comments_only() {
332 let old = "// old comment\nfn foo() {\n bar();\n}\n";
333 let new = "// new comment\nfn foo() {\n bar();\n}\n";
334 let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
335 assert_eq!(kind, ModificationKind::CommentsOnly);
336 assert_eq!(importance, ChangeImportance::Low);
337 }
338
339 #[test]
340 fn test_imports_only() {
341 let old = "use std::io;\n\nfn foo() {\n bar();\n}\n";
342 let new = "use std::io;\nuse std::fs;\n\nfn foo() {\n bar();\n}\n";
343 let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
344 assert_eq!(kind, ModificationKind::ImportsOnly);
345 assert_eq!(importance, ChangeImportance::Low);
346 }
347
348 #[test]
349 fn test_parse_error_fallback() {
350 let old = "some content here\n";
352 let new = "some content here\nwith additions\n";
353 let (kind, importance) = classify_modification(Path::new("test.xyz"), old, new);
354 assert_eq!(kind, ModificationKind::Logic);
356 assert_eq!(importance, ChangeImportance::High);
357 }
358
359 #[test]
360 fn test_formatting_only_unknown_lang() {
361 let old = "foo bar baz\n";
363 let new = "foo bar baz\n";
364 let (kind, importance) = classify_modification(Path::new("test.xyz"), old, new);
365 assert_eq!(kind, ModificationKind::FormattingOnly);
366 assert_eq!(importance, ChangeImportance::Noise);
367 }
368
369 #[test]
370 fn test_classify_with_parsed_matches_direct_classifier() {
371 let old = "use std::io;\n\nfn compute() -> i32 {\n 1\n}\n";
372 let new = "use std::io;\nuse std::fs;\n\nfn compute() -> i32 {\n 1\n}\n";
373 let old_ast = ParsedFile::parse(old, Language::Rust).expect("old Rust should parse");
374 let new_ast = ParsedFile::parse(new, Language::Rust).expect("new Rust should parse");
375
376 let direct = classify_modification_with_confidence(Path::new("test.rs"), old, new);
377 let cached = classify_modification_with_parsed(old, new, &old_ast, &new_ast);
378
379 assert_eq!(cached, direct);
380 }
381}