cs/search/
pattern_match.rs

1use crate::config::default_patterns;
2use crate::error::Result;
3use crate::parse::translation::TranslationEntry;
4use crate::search::text_search::TextSearcher;
5use regex::Regex;
6use std::path::PathBuf;
7
8/// Represents a code reference to a translation key
9#[derive(Debug, Clone, PartialEq)]
10pub struct CodeReference {
11    /// Path to the file containing the reference
12    pub file: PathBuf,
13    /// Line number (1-indexed)
14    pub line: usize,
15    /// The regex pattern that matched
16    pub pattern: String,
17    /// The actual line of code containing the match
18    pub context: String,
19    /// The translation key path that was matched
20    pub key_path: String,
21}
22
23/// Pattern matcher for finding i18n key usage in code
24pub struct PatternMatcher {
25    exclusions: Vec<String>,
26    searcher: TextSearcher,
27    patterns: Vec<Regex>,
28}
29
30impl PatternMatcher {
31    /// Create a new PatternMatcher with default patterns
32    pub fn new(base_dir: PathBuf) -> Self {
33        Self {
34            exclusions: Vec::new(),
35            searcher: TextSearcher::new(base_dir),
36            patterns: default_patterns(),
37        }
38    }
39
40    /// Create a PatternMatcher with custom patterns
41    pub fn with_patterns(patterns: Vec<Regex>, base_dir: PathBuf) -> Self {
42        Self {
43            exclusions: Vec::new(),
44            searcher: TextSearcher::new(base_dir),
45            patterns,
46        }
47    }
48
49    /// Set exclusion patterns (file or directory names to ignore)
50    pub fn set_exclusions(&mut self, exclusions: Vec<String>) {
51        self.exclusions = exclusions;
52    }
53
54    /// Find all code references for a given translation key
55    pub fn find_usages(&self, key_path: &str) -> Result<Vec<CodeReference>> {
56        // Search for the key path using ripgrep
57        let matches = self.searcher.search(key_path)?;
58
59        let mut code_refs = Vec::new();
60
61        for m in matches {
62            // Apply exclusions: skip if any exclusion matches the file path
63            let file_str = m.file.to_string_lossy();
64            if self.exclusions.iter().any(|ex| file_str.contains(ex)) {
65                continue;
66            }
67
68            // Skip tool's own source files and documentation (cross-platform)
69            let file_str = m.file.to_string_lossy().to_lowercase();
70            let path_components: Vec<_> = m
71                .file
72                .components()
73                .map(|c| c.as_os_str().to_string_lossy().to_lowercase())
74                .collect();
75
76            // Check if path starts with "src" or "tests" (but not "tests/fixtures")
77            let skip_file = !path_components.is_empty()
78                && (path_components[0] == "src"
79                    || (path_components[0] == "tests"
80                        && (path_components.len() < 2 || path_components[1] != "fixtures")));
81
82            // Also skip markdown files
83            if skip_file
84                || file_str.ends_with("readme.md")
85                || file_str.ends_with("evaluation.md")
86                || file_str.ends_with(".md")
87            {
88                continue;
89            }
90
91            // Try to match against each pattern
92            for pattern in &self.patterns {
93                if let Some(captures) = pattern.captures(&m.content) {
94                    // Extract the key from the capture group
95                    if let Some(captured_key) = captures.get(1) {
96                        if captured_key.as_str() == key_path {
97                            code_refs.push(CodeReference {
98                                file: m.file.clone(),
99                                line: m.line,
100                                pattern: pattern.as_str().to_string(),
101                                context: m.content.clone(),
102                                key_path: key_path.to_string(),
103                            });
104                            break; // Found a match, no need to check other patterns
105                        }
106                    }
107                }
108            }
109        }
110
111        Ok(code_refs)
112    }
113
114    /// Find usages for multiple translation entries
115    pub fn find_usages_batch(&self, entries: &[TranslationEntry]) -> Result<Vec<CodeReference>> {
116        let mut all_refs = Vec::new();
117
118        for entry in entries {
119            let refs = self.find_usages(&entry.key)?;
120            all_refs.extend(refs);
121        }
122
123        Ok(all_refs)
124    }
125}
126
127impl Default for PatternMatcher {
128    fn default() -> Self {
129        Self::new(std::env::current_dir().unwrap())
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_pattern_matcher_creation() {
139        let matcher = PatternMatcher::new(std::env::current_dir().unwrap());
140        assert!(!matcher.patterns.is_empty());
141    }
142
143    #[test]
144    fn test_code_reference_creation() {
145        let code_ref = CodeReference {
146            file: PathBuf::from("test.rb"),
147            line: 10,
148            pattern: r#"I18n\.t\(['"]([^'"]+)['"]\)"#.to_string(),
149            context: r#"I18n.t('invoice.labels.add_new')"#.to_string(),
150            key_path: "invoice.labels.add_new".to_string(),
151        };
152
153        assert_eq!(code_ref.file, PathBuf::from("test.rb"));
154        assert_eq!(code_ref.line, 10);
155        assert_eq!(code_ref.key_path, "invoice.labels.add_new");
156    }
157
158    #[test]
159    fn test_pattern_matcher_with_custom_patterns() {
160        let custom_patterns = vec![Regex::new(r#"custom\.t\(['"]([^'"]+)['"]\)"#).unwrap()];
161        let matcher =
162            PatternMatcher::with_patterns(custom_patterns, std::env::current_dir().unwrap());
163        assert_eq!(matcher.patterns.len(), 1);
164    }
165}