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: true,
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
142///
143/// # Rust Book Reference
144///
145/// **Chapter 9.2: Recoverable Errors with Result**
146/// https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html
147///
148/// # Educational Notes - The `#[must_use]` Attribute
149///
150/// The `#[must_use]` attribute causes a compiler warning if the Result is ignored:
151///
152/// ```rust,ignore
153/// run_search(query);  // WARNING: unused Result that must be used
154/// ```
155///
156/// This prevents accidentally ignoring errors. You must either:
157/// - Handle the error: `match run_search(query) { Ok(r) => ..., Err(e) => ... }`
158/// - Propagate with `?`: `let result = run_search(query)?;`
159/// - Explicitly ignore: `let _ = run_search(query);`
160///
161/// **Why this matters:**
162/// - Rust doesn't have exceptions - errors must be explicitly handled
163/// - Ignoring a Result means ignoring potential errors
164/// - `#[must_use]` makes error handling explicit and intentional
165#[must_use = "this function returns a Result that should be handled"]
166pub fn run_search(query: SearchQuery) -> Result<SearchResult> {
167    // Determine the base directory to search
168    let base_dir = query
169        .base_dir
170        .clone()
171        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
172
173    // Compute exclusions: Default (based on project type) + Manual (from query)
174    let project_type = config::detect_project_type(&base_dir);
175    let mut exclusions: Vec<String> = config::get_default_exclusions(project_type)
176        .iter()
177        .map(|&s| s.to_string())
178        .collect();
179    exclusions.extend(query.exclude_patterns.clone());
180
181    // Step 1: Extract translation entries matching the search text
182    let mut extractor = KeyExtractor::new();
183    extractor.set_exclusions(exclusions.clone());
184    extractor.set_verbose(query.verbose);
185    extractor.set_quiet(query.quiet);
186    extractor.set_case_sensitive(query.case_sensitive);
187    let translation_entries = extractor.extract(&base_dir, &query.text)?;
188
189    // Step 2: Find code references for each translation entry
190    // Search for full key AND partial keys (for namespace caching patterns)
191    let mut matcher = PatternMatcher::new(base_dir.clone());
192    matcher.set_exclusions(exclusions.clone());
193    let mut all_code_refs = Vec::new();
194
195    for entry in &translation_entries {
196        // Generate all key variations (full key + partial keys)
197        let key_variations = generate_partial_keys(&entry.key);
198
199        // Search for each key variation
200        for key in &key_variations {
201            let code_refs = matcher.find_usages(key)?;
202            all_code_refs.extend(code_refs);
203        }
204    }
205
206    // Step 3: Perform direct text search for the query text
207    // This ensures we find hardcoded text even if no translation keys are found
208    let text_searcher = TextSearcher::new(base_dir.clone())
209        .case_sensitive(query.case_sensitive)
210        .word_match(query.word_match)
211        .is_regex(query.is_regex)
212        .add_globs(query.include_patterns.clone())
213        .add_exclusions(exclusions.clone())
214        .respect_gitignore(true); // Always respect gitignore for now
215
216    if let Ok(direct_matches) = text_searcher.search(&query.text) {
217        for m in direct_matches {
218            // Filter out matches that are in translation files (already handled)
219            let path_str = m.file.to_string_lossy();
220            if path_str.ends_with(".yml")
221                || path_str.ends_with(".yaml")
222                || path_str.ends_with(".json")
223            {
224                continue;
225            }
226
227            // Apply exclusions
228            if exclusions.iter().any(|ex| path_str.contains(ex)) {
229                continue;
230            }
231
232            // Convert Match to CodeReference
233            all_code_refs.push(CodeReference {
234                file: m.file,
235                line: m.line,
236                pattern: "Direct Match".to_string(),
237                context: m.content,
238                key_path: query.text.clone(), // Use the search text as the "key"
239            });
240        }
241    }
242
243    // Deduplicate code references (in case same reference matches multiple key variations)
244    // We prioritize "traced" matches (where key_path != query) over "direct" matches (where key_path == query)
245    // This ensures that if we have both for the same line, we keep the one that links to a translation key.
246    all_code_refs.sort_by(|a, b| {
247        a.file.cmp(&b.file).then(a.line.cmp(&b.line)).then_with(|| {
248            let a_is_direct = a.key_path == query.text;
249            let b_is_direct = b.key_path == query.text;
250            // We want traced (false) to come before direct (true) so it is kept by dedup
251            a_is_direct.cmp(&b_is_direct)
252        })
253    });
254    all_code_refs.dedup_by(|a, b| a.file == b.file && a.line == b.line);
255
256    Ok(SearchResult {
257        query: query.text,
258        translation_entries,
259        code_references: all_code_refs,
260    })
261}
262
263/// Orchestrates the call graph tracing process
264///
265/// This function:
266/// 1. Finds the starting function definition
267/// 2. Extracts function calls or callers based on the direction
268/// 3. Builds a call graph tree up to the specified depth
269///
270/// # Arguments
271/// * `query` - Configuration for the trace operation
272///
273/// # Returns
274/// A `CallTree` representing the call graph, or `None` if the start function is not found.
275#[must_use = "this function returns a Result that should be handled"]
276pub fn run_trace(query: TraceQuery) -> Result<Option<CallTree>> {
277    let base_dir = query
278        .base_dir
279        .clone()
280        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
281
282    let mut finder = FunctionFinder::new(base_dir.clone());
283    if let Some(start_fn) = finder.find_function(&query.function_name) {
284        let extractor = CallExtractor::new(base_dir);
285        let mut builder =
286            CallGraphBuilder::new(query.direction, query.max_depth, &mut finder, &extractor);
287        builder.build_trace(&start_fn)
288    } else {
289        Ok(None)
290    }
291}
292
293/// Helper function to filter translation files from search results
294pub fn filter_translation_files(matches: &[Match]) -> Vec<PathBuf> {
295    matches
296        .iter()
297        .filter(|m| {
298            let path = m.file.to_string_lossy();
299            path.ends_with(".yml") || path.ends_with(".yaml") || path.ends_with(".json")
300        })
301        .map(|m| m.file.clone())
302        .collect()
303}
304
305/// Generate partial keys from a full translation key for common i18n patterns
306///
307/// For a key like "invoice.labels.add_new", this generates:
308/// - "invoice.labels.add_new" (full key)
309/// - "labels.add_new" (without first segment - namespace pattern)
310/// - "invoice.labels" (without last segment - parent namespace pattern)
311pub fn generate_partial_keys(full_key: &str) -> Vec<String> {
312    let mut keys = Vec::new();
313
314    // Always include the full key
315    keys.push(full_key.to_string());
316
317    let segments: Vec<&str> = full_key.split('.').collect();
318
319    // Only generate partial keys if we have at least 2 segments
320    if segments.len() >= 2 {
321        // Generate all suffixes with at least 2 segments
322        // e.g. for "a.b.c.d":
323        // - "b.c.d" (skip 1)
324        // - "c.d"   (skip 2)
325        for i in 1..segments.len() {
326            if segments.len() - i >= 2 {
327                keys.push(segments[i..].join("."));
328            }
329        }
330
331        // Generate key without last segment (e.g., "invoice.labels" from "invoice.labels.add_new")
332        // This matches patterns like: labels = I18n.t('invoice.labels'); labels.t('add_new')
333        if segments.len() > 1 {
334            let without_last = segments[..segments.len() - 1].join(".");
335            // Avoid duplicates if without_last happens to be one of the suffixes (unlikely but possible)
336            if !keys.contains(&without_last) {
337                keys.push(without_last);
338            }
339        }
340    }
341
342    keys
343}