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 raw_base_dir = query
169        .base_dir
170        .clone()
171        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
172
173    // Handle case where base_dir is a file vs directory
174    let (search_dir, specific_file) = if raw_base_dir.is_file() {
175        // If it's a file, search in its parent directory but only that specific file
176        let parent_dir = raw_base_dir
177            .parent()
178            .map(|p| p.to_path_buf())
179            .unwrap_or_else(|| PathBuf::from("."));
180        (parent_dir, Some(raw_base_dir.clone()))
181    } else {
182        // If it's a directory, search the whole directory
183        (raw_base_dir.clone(), None)
184    };
185
186    // Use the search directory for project type detection
187    let project_type = config::detect_project_type(&search_dir);
188    let mut exclusions: Vec<String> = config::get_default_exclusions(project_type)
189        .iter()
190        .map(|&s| s.to_string())
191        .collect();
192    exclusions.extend(query.exclude_patterns.clone());
193
194    // Step 1: Extract translation entries matching the search text
195    // Only search for translation entries if we're not searching a specific file
196    let translation_entries = if specific_file.is_none() {
197        let mut extractor = KeyExtractor::new();
198        extractor.set_exclusions(exclusions.clone());
199        extractor.set_verbose(query.verbose);
200        extractor.set_quiet(query.quiet);
201        extractor.set_case_sensitive(query.case_sensitive);
202        extractor.extract(&search_dir, &query.text)?
203    } else {
204        Vec::new() // Skip translation search for specific files
205    };
206
207    // Step 2: Find code references for each translation entry
208    // Search for full key AND partial keys (for namespace caching patterns)
209    let mut all_code_refs = Vec::new();
210
211    if specific_file.is_none() {
212        let mut matcher = PatternMatcher::new(search_dir.clone());
213        matcher.set_exclusions(exclusions.clone());
214
215        for entry in &translation_entries {
216            // Generate all key variations (full key + partial keys)
217            let key_variations = generate_partial_keys(&entry.key);
218
219            // Search for each key variation
220            for key in &key_variations {
221                let code_refs = matcher.find_usages(key)?;
222                all_code_refs.extend(code_refs);
223            }
224        }
225    }
226
227    // Step 3: Perform direct text search for the query text
228    // This ensures we find hardcoded text even if no translation keys are found
229    let text_searcher = TextSearcher::new(search_dir.clone())
230        .case_sensitive(query.case_sensitive)
231        .word_match(query.word_match)
232        .is_regex(query.is_regex)
233        .add_globs(query.include_patterns.clone())
234        .add_exclusions(exclusions.clone())
235        .respect_gitignore(true); // Always respect gitignore for now
236
237    if let Ok(direct_matches) = text_searcher.search(&query.text) {
238        for m in direct_matches {
239            // If searching a specific file, only include matches from that file
240            if let Some(ref target_file) = specific_file {
241                if m.file != *target_file {
242                    continue;
243                }
244            }
245
246            // Filter out matches that are in translation files (already handled)
247            // But only if we're not searching a specific file
248            let path_str = m.file.to_string_lossy();
249            if specific_file.is_none()
250                && (path_str.ends_with(".yml")
251                    || path_str.ends_with(".yaml")
252                    || path_str.ends_with(".json")
253                    || path_str.ends_with(".js"))
254            {
255                continue;
256            }
257
258            // Apply exclusions
259            if exclusions.iter().any(|ex| path_str.contains(ex)) {
260                continue;
261            }
262
263            // Convert Match to CodeReference
264            all_code_refs.push(CodeReference {
265                file: m.file.clone(),
266                line: m.line,
267                pattern: "Direct Match".to_string(),
268                context: m.content.clone(),
269                key_path: query.text.clone(), // Use the search text as the "key"
270                context_before: m.context_before.clone(),
271                context_after: m.context_after.clone(),
272            });
273        }
274    }
275
276    // Deduplicate code references (in case same reference matches multiple key variations)
277    // We prioritize "traced" matches (where key_path != query) over "direct" matches (where key_path == query)
278    // This ensures that if we have both for the same line, we keep the one that links to a translation key.
279    all_code_refs.sort_by(|a, b| {
280        a.file.cmp(&b.file).then(a.line.cmp(&b.line)).then_with(|| {
281            let a_is_direct = a.key_path == query.text;
282            let b_is_direct = b.key_path == query.text;
283            // We want traced (false) to come before direct (true) so it is kept by dedup
284            a_is_direct.cmp(&b_is_direct)
285        })
286    });
287    all_code_refs.dedup_by(|a, b| a.file == b.file && a.line == b.line);
288
289    Ok(SearchResult {
290        query: query.text,
291        translation_entries,
292        code_references: all_code_refs,
293    })
294}
295
296/// Orchestrates the call graph tracing process
297///
298/// This function:
299/// 1. Finds the starting function definition
300/// 2. Extracts function calls or callers based on the direction
301/// 3. Builds a call graph tree up to the specified depth
302///
303/// # Arguments
304/// * `query` - Configuration for the trace operation
305///
306/// # Returns
307/// A `CallTree` representing the call graph, or `None` if the start function is not found.
308#[must_use = "this function returns a Result that should be handled"]
309pub fn run_trace(query: TraceQuery) -> Result<Option<CallTree>> {
310    let base_dir = query
311        .base_dir
312        .clone()
313        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
314
315    let mut finder = FunctionFinder::new(base_dir.clone());
316    if let Some(start_fn) = finder.find_function(&query.function_name) {
317        let extractor = CallExtractor::new(base_dir);
318        let mut builder =
319            CallGraphBuilder::new(query.direction, query.max_depth, &mut finder, &extractor);
320        builder.build_trace(&start_fn)
321    } else {
322        Ok(None)
323    }
324}
325
326/// Helper function to filter translation files from search results
327pub fn filter_translation_files(matches: &[Match]) -> Vec<PathBuf> {
328    matches
329        .iter()
330        .filter(|m| {
331            let path = m.file.to_string_lossy();
332            path.ends_with(".yml") || path.ends_with(".yaml") || path.ends_with(".json")
333        })
334        .map(|m| m.file.clone())
335        .collect()
336}
337
338/// Generate partial keys from a full translation key for common i18n patterns
339///
340/// For a key like "invoice.labels.add_new", this generates:
341/// - "invoice.labels.add_new" (full key)
342/// - "labels.add_new" (without first segment - namespace pattern)
343/// - "invoice.labels" (without last segment - parent namespace pattern)
344pub fn generate_partial_keys(full_key: &str) -> Vec<String> {
345    let mut keys = Vec::new();
346
347    // Always include the full key
348    keys.push(full_key.to_string());
349
350    let segments: Vec<&str> = full_key.split('.').collect();
351
352    // Only generate partial keys if we have at least 2 segments
353    if segments.len() >= 2 {
354        // Generate all suffixes with at least 2 segments
355        // e.g. for "a.b.c.d":
356        // - "b.c.d" (skip 1)
357        // - "c.d"   (skip 2)
358        for i in 1..segments.len() {
359            if segments.len() - i >= 2 {
360                keys.push(segments[i..].join("."));
361            }
362        }
363
364        // Generate key without last segment (e.g., "invoice.labels" from "invoice.labels.add_new")
365        // This matches patterns like: labels = I18n.t('invoice.labels'); labels.t('add_new')
366        if segments.len() > 1 {
367            let without_last = segments[..segments.len() - 1].join(".");
368            // Avoid duplicates if without_last happens to be one of the suffixes (unlikely but possible)
369            if !keys.contains(&without_last) {
370                keys.push(without_last);
371            }
372        }
373    }
374
375    keys
376}