cs/
lib.rs

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