cs/
lib.rs

1pub mod cache;
2pub mod config;
3pub mod error;
4pub mod output;
5pub mod parse;
6pub mod search;
7pub mod trace;
8pub mod tree;
9
10use std::path::PathBuf;
11
12// Re-export commonly used types
13pub use cache::SearchResultCache;
14pub use config::default_patterns;
15pub use error::{Result, SearchError};
16pub use output::TreeFormatter;
17pub use parse::{KeyExtractor, TranslationEntry, YamlParser};
18pub use search::{CodeReference, FileMatch, FileSearcher, Match, PatternMatcher, TextSearcher};
19pub use trace::{
20    CallExtractor, CallGraphBuilder, CallNode, CallTree, FunctionDef, FunctionFinder,
21    TraceDirection,
22};
23pub use tree::{Location, NodeType, ReferenceTree, ReferenceTreeBuilder, TreeNode};
24
25/// Query parameters for tracing
26#[derive(Debug, Clone)]
27pub struct TraceQuery {
28    pub function_name: String,
29    pub direction: TraceDirection,
30    pub max_depth: usize,
31    pub base_dir: Option<PathBuf>,
32    pub exclude_patterns: Vec<String>,
33}
34
35impl TraceQuery {
36    pub fn new(function_name: String, direction: TraceDirection, max_depth: usize) -> Self {
37        Self {
38            function_name,
39            direction,
40            max_depth,
41            base_dir: None,
42            exclude_patterns: Vec::new(),
43        }
44    }
45
46    pub fn with_base_dir(mut self, base_dir: PathBuf) -> Self {
47        self.base_dir = Some(base_dir);
48        self
49    }
50
51    pub fn with_exclusions(mut self, exclusions: Vec<String>) -> Self {
52        self.exclude_patterns = exclusions;
53        self
54    }
55}
56
57/// Query parameters for searching
58#[derive(Debug, Clone)]
59pub struct SearchQuery {
60    pub text: String,
61    pub case_sensitive: bool,
62    pub word_match: bool,
63    pub is_regex: bool,
64    pub base_dir: Option<PathBuf>,
65    pub exclude_patterns: Vec<String>,
66    pub include_patterns: Vec<String>,
67    pub verbose: bool,
68    pub quiet: bool, // Suppress progress indicators (for --simple mode)
69}
70
71impl SearchQuery {
72    pub fn new(text: String) -> Self {
73        Self {
74            text,
75            case_sensitive: false,
76            word_match: false,
77            is_regex: false,
78            base_dir: None,
79            exclude_patterns: Vec::new(),
80            include_patterns: Vec::new(),
81            verbose: false,
82            quiet: false,
83        }
84    }
85
86    pub fn with_word_match(mut self, word_match: bool) -> Self {
87        self.word_match = word_match;
88        self
89    }
90
91    pub fn with_regex(mut self, is_regex: bool) -> Self {
92        self.is_regex = is_regex;
93        self
94    }
95
96    pub fn with_includes(mut self, includes: Vec<String>) -> Self {
97        self.include_patterns = includes;
98        self
99    }
100
101    pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
102        self.case_sensitive = case_sensitive;
103        self
104    }
105
106    pub fn with_base_dir(mut self, base_dir: PathBuf) -> Self {
107        self.base_dir = Some(base_dir);
108        self
109    }
110
111    pub fn with_exclusions(mut self, exclusions: Vec<String>) -> Self {
112        self.exclude_patterns = exclusions;
113        self
114    }
115
116    pub fn with_verbose(mut self, verbose: bool) -> Self {
117        self.verbose = verbose;
118        self
119    }
120
121    pub fn with_quiet(mut self, quiet: bool) -> Self {
122        self.quiet = quiet;
123        self
124    }
125}
126
127/// Result of a search operation
128#[derive(Debug)]
129pub struct SearchResult {
130    pub query: String,
131    pub translation_entries: Vec<TranslationEntry>,
132    pub code_references: Vec<CodeReference>,
133}
134
135/// Main orchestrator function that coordinates the entire search workflow
136///
137/// This function:
138/// 1. Searches for translation entries matching the query text
139/// 2. Extracts translation keys from YAML files
140/// 3. Finds code references for each translation key
141/// 4. Returns a SearchResult with all findings
142pub fn run_search(query: SearchQuery) -> Result<SearchResult> {
143    // Determine the base directory to search
144    let base_dir = query
145        .base_dir
146        .clone()
147        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
148
149    // Compute exclusions: Default (based on project type) + Manual (from query)
150    let project_type = config::detect_project_type(&base_dir);
151    let mut exclusions: Vec<String> = config::get_default_exclusions(project_type)
152        .iter()
153        .map(|&s| s.to_string())
154        .collect();
155    exclusions.extend(query.exclude_patterns.clone());
156
157    // Step 1: Extract translation entries matching the search text
158    let mut extractor = KeyExtractor::new();
159    extractor.set_exclusions(exclusions.clone());
160    extractor.set_verbose(query.verbose);
161    extractor.set_quiet(query.quiet);
162    let translation_entries = extractor.extract(&base_dir, &query.text)?;
163
164    // Step 2: Find code references for each translation entry
165    // Search for full key AND partial keys (for namespace caching patterns)
166    let mut matcher = PatternMatcher::new(base_dir.clone());
167    matcher.set_exclusions(exclusions.clone());
168    let mut all_code_refs = Vec::new();
169
170    for entry in &translation_entries {
171        // Generate all key variations (full key + partial keys)
172        let key_variations = generate_partial_keys(&entry.key);
173
174        // Search for each key variation
175        for key in &key_variations {
176            let code_refs = matcher.find_usages(key)?;
177            all_code_refs.extend(code_refs);
178        }
179    }
180
181    // Step 3: Perform direct text search for the query text
182    // This ensures we find hardcoded text even if no translation keys are found
183    let text_searcher = TextSearcher::new(base_dir.clone())
184        .case_sensitive(query.case_sensitive)
185        .word_match(query.word_match)
186        .is_regex(query.is_regex)
187        .add_globs(query.include_patterns.clone())
188        .add_exclusions(exclusions.clone())
189        .respect_gitignore(true); // Always respect gitignore for now
190
191    if let Ok(direct_matches) = text_searcher.search(&query.text) {
192        for m in direct_matches {
193            // Filter out matches that are in translation files (already handled)
194            let path_str = m.file.to_string_lossy();
195            if path_str.ends_with(".yml")
196                || path_str.ends_with(".yaml")
197                || path_str.ends_with(".json")
198            {
199                continue;
200            }
201
202            // Apply exclusions
203            if exclusions.iter().any(|ex| path_str.contains(ex)) {
204                continue;
205            }
206
207            // Convert Match to CodeReference
208            all_code_refs.push(CodeReference {
209                file: m.file,
210                line: m.line,
211                pattern: "Direct Match".to_string(),
212                context: m.content,
213                key_path: query.text.clone(), // Use the search text as the "key"
214            });
215        }
216    }
217
218    // Deduplicate code references (in case same reference matches multiple key variations)
219    // We prioritize "traced" matches (where key_path != query) over "direct" matches (where key_path == query)
220    // This ensures that if we have both for the same line, we keep the one that links to a translation key.
221    all_code_refs.sort_by(|a, b| {
222        a.file.cmp(&b.file).then(a.line.cmp(&b.line)).then_with(|| {
223            let a_is_direct = a.key_path == query.text;
224            let b_is_direct = b.key_path == query.text;
225            // We want traced (false) to come before direct (true) so it is kept by dedup
226            a_is_direct.cmp(&b_is_direct)
227        })
228    });
229    all_code_refs.dedup_by(|a, b| a.file == b.file && a.line == b.line);
230
231    Ok(SearchResult {
232        query: query.text,
233        translation_entries,
234        code_references: all_code_refs,
235    })
236}
237
238/// Orchestrates the call graph tracing process
239///
240/// This function:
241/// 1. Finds the starting function definition
242/// 2. Extracts function calls or callers based on the direction
243/// 3. Builds a call graph tree up to the specified depth
244///
245/// # Arguments
246/// * `query` - Configuration for the trace operation
247///
248/// # Returns
249/// A `CallTree` representing the call graph, or `None` if the start function is not found.
250pub fn run_trace(query: TraceQuery) -> Result<Option<CallTree>> {
251    let base_dir = query
252        .base_dir
253        .clone()
254        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
255
256    let mut finder = FunctionFinder::new(base_dir.clone());
257    if let Some(start_fn) = finder.find_function(&query.function_name) {
258        let extractor = CallExtractor::new(base_dir);
259        let mut builder =
260            CallGraphBuilder::new(query.direction, query.max_depth, &mut finder, &extractor);
261        builder.build_trace(&start_fn)
262    } else {
263        Ok(None)
264    }
265}
266
267/// Helper function to filter translation files from search results
268pub fn filter_translation_files(matches: &[Match]) -> Vec<PathBuf> {
269    matches
270        .iter()
271        .filter(|m| {
272            let path = m.file.to_string_lossy();
273            path.ends_with(".yml") || path.ends_with(".yaml") || path.ends_with(".json")
274        })
275        .map(|m| m.file.clone())
276        .collect()
277}
278
279/// Generate partial keys from a full translation key for common i18n patterns
280///
281/// For a key like "invoice.labels.add_new", this generates:
282/// - "invoice.labels.add_new" (full key)
283/// - "labels.add_new" (without first segment - namespace pattern)
284/// - "invoice.labels" (without last segment - parent namespace pattern)
285pub fn generate_partial_keys(full_key: &str) -> Vec<String> {
286    let mut keys = Vec::new();
287
288    // Always include the full key
289    keys.push(full_key.to_string());
290
291    let segments: Vec<&str> = full_key.split('.').collect();
292
293    // Only generate partial keys if we have at least 2 segments
294    if segments.len() >= 2 {
295        // Generate all suffixes with at least 2 segments
296        // e.g. for "a.b.c.d":
297        // - "b.c.d" (skip 1)
298        // - "c.d"   (skip 2)
299        for i in 1..segments.len() {
300            if segments.len() - i >= 2 {
301                keys.push(segments[i..].join("."));
302            }
303        }
304
305        // Generate key without last segment (e.g., "invoice.labels" from "invoice.labels.add_new")
306        // This matches patterns like: labels = I18n.t('invoice.labels'); labels.t('add_new')
307        if segments.len() > 1 {
308            let without_last = segments[..segments.len() - 1].join(".");
309            // Avoid duplicates if without_last happens to be one of the suffixes (unlikely but possible)
310            if !keys.contains(&without_last) {
311                keys.push(without_last);
312            }
313        }
314    }
315
316    keys
317}