acp/annotate/
analyzer.rs

1//! @acp:module "Annotation Analyzer"
2//! @acp:summary "Analyzes code to identify annotation gaps and existing coverage"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # Annotation Analyzer
8//!
9//! Provides functionality for analyzing source files to:
10//! - Detect existing ACP annotations
11//! - Identify symbols lacking annotations
12//! - Calculate annotation coverage metrics
13//! - Extract doc comments for potential conversion
14
15use 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
27/// @acp:summary "Analyzes source files for ACP annotation coverage"
28/// @acp:lock normal
29pub struct Analyzer {
30    /// Configuration for analysis
31    config: Config,
32
33    /// AST parser for symbol extraction
34    ast_parser: AstParser,
35
36    /// Regex for detecting @acp: annotations
37    annotation_pattern: Regex,
38
39    /// Annotation level for gap detection
40    level: AnnotateLevel,
41}
42
43impl Analyzer {
44    /// @acp:summary "Creates a new analyzer with the given configuration"
45    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    /// @acp:summary "Sets the annotation level for gap detection"
58    pub fn with_level(mut self, level: AnnotateLevel) -> Self {
59        self.level = level;
60        self
61    }
62
63    /// @acp:summary "Discovers files to analyze based on configuration"
64    ///
65    /// Walks the directory tree and filters files based on include/exclude
66    /// patterns from the configuration.
67    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            // Skip directories
78            if path.is_dir() {
79                continue;
80            }
81
82            // Check if file matches include patterns
83            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            // Check if file matches exclude patterns
95            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            // Apply optional filter
106            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    /// @acp:summary "Analyzes a single file for annotation coverage"
121    ///
122    /// Parses the file, extracts symbols and existing annotations,
123    /// and identifies gaps where annotations are missing.
124    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        // Detect language from extension
129        let language = self.detect_language(file_path);
130
131        let mut result = AnalysisResult::new(&path_str, &language);
132
133        // Extract existing annotations from comments
134        result.existing_annotations = self.extract_existing_annotations(&content, &path_str);
135
136        // Parse AST and extract symbols
137        if let Ok(symbols) = self.ast_parser.parse_file(file_path, &content) {
138            // Associate annotations with their correct symbol targets
139            self.associate_annotations_with_symbols(&mut result.existing_annotations, &symbols);
140
141            // Build map of annotated targets -> annotation types they have
142            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            // Find gaps (symbols with missing annotation types)
153            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                    // Get existing annotation types for this target
158                    let existing_types = annotated_types.get(target).cloned().unwrap_or_default();
159
160                    // Determine which annotation types are missing
161                    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                        // Set doc comment with calculated line range
173                        if let Some(doc) = &symbol.doc_comment {
174                            // Try to find actual doc comment boundaries in source
175                            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                                // Fallback to calculated range
181                                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            // Check for file-level annotation gap
199            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        // Calculate coverage
224        result.calculate_coverage();
225
226        Ok(result)
227    }
228
229    /// @acp:summary "Detects the programming language from file extension"
230    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    /// @acp:summary "Extracts existing @acp: annotations from file content"
247    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; // Convert to 1-indexed
257
258            // Check for @acp: annotation
259            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    /// @acp:summary "Associates annotations with their correct symbol targets"
278    ///
279    /// For each annotation, finds the symbol that immediately follows it
280    /// (within a reasonable line distance) and updates the annotation's target.
281    fn associate_annotations_with_symbols(
282        &self,
283        annotations: &mut [ExistingAnnotation],
284        symbols: &[ExtractedSymbol],
285    ) {
286        // Sort symbols by start line for efficient lookup
287        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            // Find the symbol that starts closest after this annotation
292            // (annotations appear in doc comments just before the symbol)
293            let annotation_line = annotation.line;
294
295            // Look for a symbol that starts within 20 lines after the annotation
296            // (doc comments can be multi-line)
297            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                // Update the target to the symbol's qualified name
303                annotation.target = symbol
304                    .qualified_name
305                    .clone()
306                    .unwrap_or_else(|| symbol.name.clone());
307            }
308            // If no symbol found, the annotation stays associated with the file path
309            // (module-level annotation)
310        }
311    }
312
313    /// @acp:summary "Parses an annotation namespace into an AnnotationType"
314    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    /// @acp:summary "Determines if a symbol should be annotated"
332    fn should_annotate_symbol(&self, symbol: &ExtractedSymbol) -> bool {
333        // Skip private symbols unless they're important
334        match symbol.visibility {
335            Visibility::Private => false,
336            Visibility::Protected | Visibility::Internal | Visibility::Crate => {
337                // Include protected/internal if they're "important" kinds
338                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    /// @acp:summary "Determines which annotation types are missing for a symbol"
351    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        // Check each annotation type at current level
359        for annotation_type in self.level.included_types() {
360            // Skip file-level only annotations for symbols
361            if matches!(annotation_type, AnnotationType::Module) {
362                continue;
363            }
364
365            // Check if this specific annotation type already exists
366            if !existing_types.contains(&annotation_type) {
367                missing.push(annotation_type);
368            }
369        }
370
371        // @acp:summary is always recommended for exported symbols
372        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    /// @acp:summary "Finds the actual doc comment range by parsing source"
383    ///
384    /// Searches backward from the symbol line to find the JSDoc/doc comment
385    /// block boundaries (/** ... */). Returns (start_line, end_line) 1-indexed.
386    fn find_doc_comment_range(&self, content: &str, symbol_line: usize) -> Option<(usize, usize)> {
387        let lines: Vec<&str> = content.lines().collect();
388
389        // symbol_line is 1-indexed, convert to 0-indexed for array access
390        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        // Search backward from symbol (excluding the symbol line itself)
398        for i in (0..symbol_line.saturating_sub(1)).rev() {
399            let line = lines.get(i).map(|s| s.trim()).unwrap_or("");
400
401            // Found end of doc comment
402            if line.ends_with("*/") && end_line.is_none() {
403                end_line = Some(i + 1); // Convert back to 1-indexed
404            }
405
406            // Found start of doc comment
407            if line.starts_with("/**") || line == "/**" {
408                start_line = Some(i + 1); // Convert back to 1-indexed
409                break;
410            }
411
412            // If we haven't found end_line yet and hit non-comment/non-whitespace, stop
413            if end_line.is_none() {
414                // Allow: empty lines, decorator lines (@...), single-line comments
415                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    /// @acp:summary "Checks if a cache exists and has been initialized"
432    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    /// @acp:summary "Calculates total coverage across multiple analysis results"
438    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        // Test the with_doc_comment_range builder method
506        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        // Create mock annotations at lines that precede symbols
525        let mut annotations = vec![
526            ExistingAnnotation {
527                target: "file.rs".to_string(), // Initially assigned to file
528                annotation_type: AnnotationType::Summary,
529                value: "MyStruct summary".to_string(),
530                line: 28, // Annotation on line 28 (near symbol at 30)
531            },
532            ExistingAnnotation {
533                target: "file.rs".to_string(),
534                annotation_type: AnnotationType::Domain,
535                value: "core".to_string(),
536                line: 29, // Another annotation on line 29
537            },
538            ExistingAnnotation {
539                target: "file.rs".to_string(),
540                annotation_type: AnnotationType::Module,
541                value: "FileModule".to_string(),
542                line: 1, // Module annotation at top (>20 lines from any symbol)
543            },
544        ];
545
546        // Create mock symbols
547        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, // Symbol starts at line 30 (within 20 lines of annotations at 28-29)
553            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        // Check that annotations on lines 28 and 29 were associated with MyStruct
571        assert_eq!(annotations[0].target, "module::MyStruct");
572        assert_eq!(annotations[1].target, "module::MyStruct");
573
574        // Module annotation at line 1 should stay as file target (symbol at 30 is >20 lines away)
575        assert_eq!(annotations[2].target, "file.rs");
576    }
577}