1use std::collections::{HashMap, HashSet};
16use std::path::{Path, PathBuf};
17
18use regex::Regex;
19use walkdir::WalkDir;
20
21use crate::ast::{AstParser, ExtractedSymbol, SymbolKind, Visibility};
22use crate::config::Config;
23use crate::error::Result;
24
25use super::{AnalysisResult, AnnotateLevel, AnnotationGap, AnnotationType, ExistingAnnotation};
26
27pub struct Analyzer {
30 config: Config,
32
33 ast_parser: AstParser,
35
36 annotation_pattern: Regex,
38
39 level: AnnotateLevel,
41}
42
43impl Analyzer {
44 pub fn new(config: &Config) -> Result<Self> {
46 let annotation_pattern =
47 Regex::new(r"@acp:([a-z][a-z0-9-]*)(?:\s+(.+))?$").expect("Invalid annotation regex");
48
49 Ok(Self {
50 config: config.clone(),
51 ast_parser: AstParser::new()?,
52 annotation_pattern,
53 level: AnnotateLevel::Standard,
54 })
55 }
56
57 pub fn with_level(mut self, level: AnnotateLevel) -> Self {
59 self.level = level;
60 self
61 }
62
63 pub fn discover_files(&self, root: &Path, filter: Option<&str>) -> Result<Vec<PathBuf>> {
68 let mut files = Vec::new();
69
70 for entry in WalkDir::new(root)
71 .follow_links(true)
72 .into_iter()
73 .filter_map(|e| e.ok())
74 {
75 let path = entry.path();
76
77 if path.is_dir() {
79 continue;
80 }
81
82 let path_str = path.to_string_lossy();
84 let matches_include = self.config.include.iter().any(|pattern| {
85 glob::Pattern::new(pattern)
86 .map(|p| p.matches(&path_str))
87 .unwrap_or(false)
88 });
89
90 if !matches_include {
91 continue;
92 }
93
94 let matches_exclude = self.config.exclude.iter().any(|pattern| {
96 glob::Pattern::new(pattern)
97 .map(|p| p.matches(&path_str))
98 .unwrap_or(false)
99 });
100
101 if matches_exclude {
102 continue;
103 }
104
105 if let Some(filter_pattern) = filter {
107 if let Ok(pattern) = glob::Pattern::new(filter_pattern) {
108 if !pattern.matches(&path_str) {
109 continue;
110 }
111 }
112 }
113
114 files.push(path.to_path_buf());
115 }
116
117 Ok(files)
118 }
119
120 pub fn analyze_file(&self, file_path: &Path) -> Result<AnalysisResult> {
125 let content = std::fs::read_to_string(file_path)?;
126 let path_str = file_path.to_string_lossy().to_string();
127
128 let language = self.detect_language(file_path);
130
131 let mut result = AnalysisResult::new(&path_str, &language);
132
133 result.existing_annotations = self.extract_existing_annotations(&content, &path_str);
135
136 if let Ok(symbols) = self.ast_parser.parse_file(file_path, &content) {
138 self.associate_annotations_with_symbols(&mut result.existing_annotations, &symbols);
140
141 let annotated_types: HashMap<String, HashSet<AnnotationType>> = {
143 let mut map: HashMap<String, HashSet<AnnotationType>> = HashMap::new();
144 for ann in &result.existing_annotations {
145 map.entry(ann.target.clone())
146 .or_default()
147 .insert(ann.annotation_type);
148 }
149 map
150 };
151
152 for symbol in &symbols {
154 if self.should_annotate_symbol(symbol) {
155 let target = symbol.qualified_name.as_ref().unwrap_or(&symbol.name);
156
157 let existing_types = annotated_types.get(target).cloned().unwrap_or_default();
159
160 let missing = self.get_missing_annotation_types(symbol, &existing_types);
162
163 if !missing.is_empty() {
164 let mut gap = AnnotationGap::new(target, symbol.start_line)
165 .with_symbol_kind(symbol.kind)
166 .with_visibility(symbol.visibility);
167
168 if symbol.exported {
169 gap = gap.exported();
170 }
171
172 if let Some(doc) = &symbol.doc_comment {
174 if let Some((start, end)) =
176 self.find_doc_comment_range(&content, symbol.start_line)
177 {
178 gap = gap.with_doc_comment_range(doc, start, end);
179 } else {
180 let doc_line_count = doc.lines().count();
182 if doc_line_count > 0 && symbol.start_line > doc_line_count {
183 let doc_end = symbol.start_line - 1;
184 let doc_start = doc_end.saturating_sub(doc_line_count - 1);
185 gap = gap.with_doc_comment_range(doc, doc_start, doc_end);
186 } else {
187 gap = gap.with_doc_comment(doc);
188 }
189 }
190 }
191
192 gap.missing = missing;
193 result.gaps.push(gap);
194 }
195 }
196 }
197
198 let file_existing_types = annotated_types.get(&path_str).cloned().unwrap_or_default();
200 let mut file_missing = Vec::new();
201
202 if !file_existing_types.contains(&AnnotationType::Module) {
203 file_missing.push(AnnotationType::Module);
204 }
205 if self.level.includes(AnnotationType::Summary)
206 && !file_existing_types.contains(&AnnotationType::Summary)
207 {
208 file_missing.push(AnnotationType::Summary);
209 }
210 if self.level.includes(AnnotationType::Domain)
211 && !file_existing_types.contains(&AnnotationType::Domain)
212 {
213 file_missing.push(AnnotationType::Domain);
214 }
215
216 if !file_missing.is_empty() {
217 let mut file_gap = AnnotationGap::new(&path_str, 1);
218 file_gap.missing = file_missing;
219 result.gaps.push(file_gap);
220 }
221 }
222
223 result.calculate_coverage();
225
226 Ok(result)
227 }
228
229 fn detect_language(&self, path: &Path) -> String {
231 path.extension()
232 .and_then(|ext| ext.to_str())
233 .map(|ext| match ext {
234 "ts" | "tsx" => "typescript",
235 "js" | "jsx" | "mjs" | "cjs" => "javascript",
236 "py" | "pyi" => "python",
237 "rs" => "rust",
238 "go" => "go",
239 "java" => "java",
240 _ => "unknown",
241 })
242 .unwrap_or("unknown")
243 .to_string()
244 }
245
246 fn extract_existing_annotations(
248 &self,
249 content: &str,
250 file_path: &str,
251 ) -> Vec<ExistingAnnotation> {
252 let mut annotations = Vec::new();
253 let current_target = file_path.to_string();
254
255 for (line_num, line) in content.lines().enumerate() {
256 let line_number = line_num + 1; if let Some(caps) = self.annotation_pattern.captures(line) {
260 let namespace = caps.get(1).map(|m| m.as_str()).unwrap_or("");
261 let value = caps.get(2).map(|m| m.as_str().trim()).unwrap_or("");
262
263 if let Some(annotation_type) = self.parse_annotation_type(namespace) {
264 annotations.push(ExistingAnnotation {
265 target: current_target.clone(),
266 annotation_type,
267 value: value.trim_matches('"').to_string(),
268 line: line_number,
269 });
270 }
271 }
272 }
273
274 annotations
275 }
276
277 fn associate_annotations_with_symbols(
282 &self,
283 annotations: &mut [ExistingAnnotation],
284 symbols: &[ExtractedSymbol],
285 ) {
286 let mut sorted_symbols: Vec<&ExtractedSymbol> = symbols.iter().collect();
288 sorted_symbols.sort_by_key(|s| s.start_line);
289
290 for annotation in annotations.iter_mut() {
291 let annotation_line = annotation.line;
294
295 let max_distance = 20;
298
299 if let Some(symbol) = sorted_symbols.iter().find(|s| {
300 s.start_line > annotation_line && s.start_line <= annotation_line + max_distance
301 }) {
302 annotation.target = symbol
304 .qualified_name
305 .clone()
306 .unwrap_or_else(|| symbol.name.clone());
307 }
308 }
311 }
312
313 fn parse_annotation_type(&self, namespace: &str) -> Option<AnnotationType> {
315 match namespace {
316 "module" => Some(AnnotationType::Module),
317 "summary" => Some(AnnotationType::Summary),
318 "domain" => Some(AnnotationType::Domain),
319 "layer" => Some(AnnotationType::Layer),
320 "lock" => Some(AnnotationType::Lock),
321 "stability" => Some(AnnotationType::Stability),
322 "deprecated" => Some(AnnotationType::Deprecated),
323 "ai-hint" => Some(AnnotationType::AiHint),
324 "ref" => Some(AnnotationType::Ref),
325 "hack" => Some(AnnotationType::Hack),
326 "lock-reason" => Some(AnnotationType::LockReason),
327 _ => None,
328 }
329 }
330
331 fn should_annotate_symbol(&self, symbol: &ExtractedSymbol) -> bool {
333 match symbol.visibility {
335 Visibility::Private => false,
336 Visibility::Protected | Visibility::Internal | Visibility::Crate => {
337 matches!(
339 symbol.kind,
340 SymbolKind::Class
341 | SymbolKind::Struct
342 | SymbolKind::Interface
343 | SymbolKind::Trait
344 )
345 }
346 Visibility::Public => true,
347 }
348 }
349
350 fn get_missing_annotation_types(
352 &self,
353 symbol: &ExtractedSymbol,
354 existing_types: &HashSet<AnnotationType>,
355 ) -> Vec<AnnotationType> {
356 let mut missing = Vec::new();
357
358 for annotation_type in self.level.included_types() {
360 if matches!(annotation_type, AnnotationType::Module) {
362 continue;
363 }
364
365 if !existing_types.contains(&annotation_type) {
367 missing.push(annotation_type);
368 }
369 }
370
371 if symbol.exported
373 && !existing_types.contains(&AnnotationType::Summary)
374 && !missing.contains(&AnnotationType::Summary)
375 {
376 missing.insert(0, AnnotationType::Summary);
377 }
378
379 missing
380 }
381
382 fn find_doc_comment_range(&self, content: &str, symbol_line: usize) -> Option<(usize, usize)> {
387 let lines: Vec<&str> = content.lines().collect();
388
389 if symbol_line == 0 || symbol_line > lines.len() {
391 return None;
392 }
393
394 let mut end_line = None;
395 let mut start_line = None;
396
397 for i in (0..symbol_line.saturating_sub(1)).rev() {
399 let line = lines.get(i).map(|s| s.trim()).unwrap_or("");
400
401 if line.ends_with("*/") && end_line.is_none() {
403 end_line = Some(i + 1); }
405
406 if line.starts_with("/**") || line == "/**" {
408 start_line = Some(i + 1); break;
410 }
411
412 if end_line.is_none() {
414 if !line.is_empty()
416 && !line.starts_with("//")
417 && !line.starts_with("@")
418 && !line.starts_with("*")
419 {
420 break;
421 }
422 }
423 }
424
425 match (start_line, end_line) {
426 (Some(s), Some(e)) if s <= e => Some((s, e)),
427 _ => None,
428 }
429 }
430
431 pub fn has_existing_cache(&self, root: &Path) -> bool {
433 let cache_path = root.join(".acp").join("acp.cache.json");
434 cache_path.exists()
435 }
436
437 pub fn calculate_total_coverage(results: &[AnalysisResult]) -> f32 {
439 if results.is_empty() {
440 return 100.0;
441 }
442
443 let total_annotated: usize = results.iter().map(|r| r.existing_annotations.len()).sum();
444 let total_gaps: usize = results.iter().map(|r| r.gaps.len()).sum();
445 let total = total_annotated + total_gaps;
446
447 if total == 0 {
448 100.0
449 } else {
450 (total_annotated as f32 / total as f32) * 100.0
451 }
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn test_detect_language() {
461 let config = Config::default();
462 let analyzer = Analyzer::new(&config).unwrap();
463
464 assert_eq!(analyzer.detect_language(Path::new("test.ts")), "typescript");
465 assert_eq!(analyzer.detect_language(Path::new("test.py")), "python");
466 assert_eq!(analyzer.detect_language(Path::new("test.rs")), "rust");
467 assert_eq!(analyzer.detect_language(Path::new("test.txt")), "unknown");
468 }
469
470 #[test]
471 fn test_parse_annotation_type() {
472 let config = Config::default();
473 let analyzer = Analyzer::new(&config).unwrap();
474
475 assert_eq!(
476 analyzer.parse_annotation_type("summary"),
477 Some(AnnotationType::Summary)
478 );
479 assert_eq!(
480 analyzer.parse_annotation_type("domain"),
481 Some(AnnotationType::Domain)
482 );
483 assert_eq!(analyzer.parse_annotation_type("unknown"), None);
484 }
485
486 #[test]
487 fn test_calculate_total_coverage() {
488 let mut result1 = AnalysisResult::new("file1.ts", "typescript");
489 result1.existing_annotations.push(ExistingAnnotation {
490 target: "file1.ts".to_string(),
491 annotation_type: AnnotationType::Module,
492 value: "Test".to_string(),
493 line: 1,
494 });
495
496 let mut result2 = AnalysisResult::new("file2.ts", "typescript");
497 result2.gaps.push(AnnotationGap::new("MyClass", 10));
498
499 let coverage = Analyzer::calculate_total_coverage(&[result1, result2]);
500 assert!((coverage - 50.0).abs() < 0.01);
501 }
502
503 #[test]
504 fn test_doc_comment_range() {
505 let gap = AnnotationGap::new("MyClass", 10).with_doc_comment_range(
507 "/// This is a doc comment\n/// Second line",
508 8,
509 9,
510 );
511
512 assert!(gap.doc_comment.is_some());
513 assert_eq!(gap.doc_comment_range, Some((8, 9)));
514 assert!(gap.doc_comment.unwrap().contains("This is a doc comment"));
515 }
516
517 #[test]
518 fn test_associate_annotations_with_symbols() {
519 use crate::ast::SymbolKind;
520
521 let config = Config::default();
522 let analyzer = Analyzer::new(&config).unwrap();
523
524 let mut annotations = vec![
526 ExistingAnnotation {
527 target: "file.rs".to_string(), annotation_type: AnnotationType::Summary,
529 value: "MyStruct summary".to_string(),
530 line: 28, },
532 ExistingAnnotation {
533 target: "file.rs".to_string(),
534 annotation_type: AnnotationType::Domain,
535 value: "core".to_string(),
536 line: 29, },
538 ExistingAnnotation {
539 target: "file.rs".to_string(),
540 annotation_type: AnnotationType::Module,
541 value: "FileModule".to_string(),
542 line: 1, },
544 ];
545
546 let symbols = vec![ExtractedSymbol {
548 name: "MyStruct".to_string(),
549 qualified_name: Some("module::MyStruct".to_string()),
550 kind: SymbolKind::Struct,
551 visibility: Visibility::Public,
552 start_line: 30, end_line: 50,
554 start_col: 0,
555 end_col: 0,
556 signature: None,
557 doc_comment: None,
558 parent: None,
559 type_info: None,
560 parameters: vec![],
561 return_type: None,
562 exported: true,
563 is_async: false,
564 is_static: false,
565 generics: vec![],
566 }];
567
568 analyzer.associate_annotations_with_symbols(&mut annotations, &symbols);
569
570 assert_eq!(annotations[0].target, "module::MyStruct");
572 assert_eq!(annotations[1].target, "module::MyStruct");
573
574 assert_eq!(annotations[2].target, "file.rs");
576 }
577}