1use crate::error::{Result, SearchError};
2use crate::parse::translation::TranslationEntry;
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7pub struct JsParser;
13
14impl JsParser {
15 pub fn parse_file(file_path: &Path) -> Result<Vec<TranslationEntry>> {
17 let content = fs::read_to_string(file_path).map_err(SearchError::Io)?;
18
19 Self::parse_content(&content, file_path)
20 }
21
22 pub fn parse_content(content: &str, file_path: &Path) -> Result<Vec<TranslationEntry>> {
24 let object_content = Self::extract_object_literal(content)?;
26
27 let parsed_object = Self::parse_object_literal(&object_content)?;
29
30 let mut entries = Vec::new();
32 Self::flatten_object(&parsed_object, String::new(), file_path, &mut entries);
33
34 Ok(entries)
35 }
36
37 fn extract_object_literal(content: &str) -> Result<String> {
39 let content = content.trim();
40
41 let start_patterns = ["export default", "module.exports =", "exports ="];
43
44 let mut object_start = None;
45 for pattern in &start_patterns {
46 if let Some(pos) = content.find(pattern) {
47 let after_pattern = &content[pos + pattern.len()..];
49 if let Some(brace_pos) = after_pattern.find('{') {
50 object_start = Some(pos + pattern.len() + brace_pos);
51 break;
52 }
53 }
54 }
55
56 let start = object_start
57 .ok_or_else(|| SearchError::Generic("No JavaScript object export found".to_string()))?;
58
59 let mut brace_count = 0;
61 let mut end = start;
62 let chars: Vec<char> = content.chars().collect();
63
64 for (i, &ch) in chars.iter().enumerate().skip(start) {
65 match ch {
66 '{' => brace_count += 1,
67 '}' => {
68 brace_count -= 1;
69 if brace_count == 0 {
70 end = i + 1;
71 break;
72 }
73 }
74 _ => {}
75 }
76 }
77
78 if brace_count != 0 {
79 return Err(SearchError::Generic(
80 "Unmatched braces in JavaScript object".to_string(),
81 ));
82 }
83
84 Ok(content[start..end].to_string())
85 }
86
87 fn parse_object_literal(content: &str) -> Result<HashMap<String, serde_json::Value>> {
89 let json_content = Self::js_to_json(content)?;
91
92 serde_json::from_str(&json_content)
94 .map_err(|e| SearchError::Generic(format!("Failed to parse JavaScript object: {}", e)))
95 }
96
97 fn js_to_json(js_content: &str) -> Result<String> {
99 let mut result = String::new();
100 let chars: Vec<char> = js_content.chars().collect();
101 let mut i = 0;
102 let mut in_string = false;
103 let mut string_char = '"';
104
105 while i < chars.len() {
106 let ch = chars[i];
107
108 match ch {
109 '"' | '\'' => {
110 if !in_string {
111 in_string = true;
112 string_char = ch;
113 result.push('"'); } else if ch == string_char {
115 in_string = false;
116 result.push('"');
117 } else {
118 result.push(ch);
119 }
120 }
121 _ if in_string => {
122 result.push(ch);
124 }
125 _ if (ch.is_alphabetic() || ch == '_') && !in_string => {
126 let mut j = i;
128 let mut prop_name = String::new();
129
130 while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
132 prop_name.push(chars[j]);
133 j += 1;
134 }
135
136 while j < chars.len() && chars[j].is_whitespace() {
138 j += 1;
139 }
140
141 if j < chars.len() && chars[j] == ':' {
143 result.push('"');
145 result.push_str(&prop_name);
146 result.push('"');
147 i = j - 1; } else {
149 result.push(ch);
151 }
152 }
153 _ => {
154 result.push(ch);
155 }
156 }
157
158 i += 1;
159 }
160
161 Ok(result)
162 }
163
164 fn flatten_object(
166 obj: &HashMap<String, serde_json::Value>,
167 prefix: String,
168 file_path: &Path,
169 entries: &mut Vec<TranslationEntry>,
170 ) {
171 for (key, value) in obj {
172 let full_key = if prefix.is_empty() {
173 key.clone()
174 } else {
175 format!("{}.{}", prefix, key)
176 };
177
178 match value {
179 serde_json::Value::String(s) => {
180 entries.push(TranslationEntry {
181 key: full_key,
182 value: s.clone(),
183 file: file_path.to_path_buf(),
184 line: 1, });
186 }
187 serde_json::Value::Object(nested_obj) => {
188 let nested_map: HashMap<String, serde_json::Value> = nested_obj
189 .iter()
190 .map(|(k, v)| (k.clone(), v.clone()))
191 .collect();
192 Self::flatten_object(&nested_map, full_key, file_path, entries);
193 }
194 serde_json::Value::Array(arr) => {
195 for (i, v) in arr.iter().enumerate() {
196 let item_key = format!("{}.{}", full_key, i);
197
198 if let serde_json::Value::String(s) = v {
199 entries.push(TranslationEntry {
200 key: item_key,
201 value: s.clone(),
202 file: file_path.to_path_buf(),
203 line: 1,
204 });
205 } else if let serde_json::Value::Object(nested_obj) = v {
206 let nested_map: HashMap<String, serde_json::Value> = nested_obj
207 .iter()
208 .map(|(k, v)| (k.clone(), v.clone()))
209 .collect();
210 Self::flatten_object(&nested_map, item_key, file_path, entries);
211 }
212 }
213 }
214 _ => {
215 }
217 }
218 }
219 }
220
221 pub fn contains_query(file_path: &Path, query: &str) -> Result<bool> {
223 use grep_matcher::Matcher;
224 use grep_regex::RegexMatcherBuilder;
225 use grep_searcher::{sinks::UTF8, SearcherBuilder};
226
227 let matcher = RegexMatcherBuilder::new()
230 .case_insensitive(true)
231 .fixed_strings(true)
232 .build(query)
233 .map_err(|e| SearchError::Generic(format!("Failed to build matcher: {}", e)))?;
234
235 let mut searcher = SearcherBuilder::new().line_number(true).build();
236
237 let mut found = false;
238
239 let _ = searcher.search_path(
241 &matcher,
242 file_path,
243 UTF8(|line_num, line| {
244 let mut stop = false;
245 let _ = matcher.find_iter(line.as_bytes(), |m| {
248 let col_num = m.start() + 1;
250
251 if let Ok(true) =
254 Self::is_translation_value(file_path, line_num as usize, col_num, query)
255 {
256 found = true;
257 stop = true;
258 return false; }
260 true });
262
263 if stop {
264 Ok(false) } else {
266 Ok(true) }
268 }),
269 );
270
271 Ok(found)
272 }
273
274 fn is_translation_value(
276 file_path: &Path,
277 line_num: usize,
278 col_num: usize,
279 _query: &str,
280 ) -> Result<bool> {
281 let content = std::fs::read_to_string(file_path).map_err(SearchError::Io)?;
282 let lines: Vec<&str> = content.lines().collect();
283
284 if line_num == 0 || line_num > lines.len() {
285 return Ok(false);
286 }
287
288 let line = lines[line_num - 1]; let match_start = col_num - 1; if let Some(colon_pos) = line.find(':') {
293 if match_start > colon_pos {
294 return Self::is_in_translation_context(file_path, line_num);
296 }
297 }
298
299 if !line.contains(':') {
301 if Self::is_in_translation_array(file_path, line_num)? {
303 return Ok(true);
304 }
305 return Self::is_multiline_string_continuation(file_path, line_num);
307 }
308
309 if line.contains(':') && match_start < line.find(':').unwrap_or(0) {
312 return Self::is_in_translation_context(file_path, line_num);
313 }
314
315 Ok(false)
316 }
317
318 fn is_in_translation_context(file_path: &Path, line_num: usize) -> Result<bool> {
320 let content = std::fs::read_to_string(file_path).map_err(SearchError::Io)?;
321 let lines: Vec<&str> = content.lines().collect();
322
323 if line_num == 0 || line_num > lines.len() {
324 return Ok(false);
325 }
326
327 let target_line_idx = line_num - 1;
328
329 for i in (0..=target_line_idx).rev() {
331 let line = lines[i].trim();
332
333 if line.contains("export default") || line.contains("module.exports") {
334 return Ok(true);
335 }
336
337 if line.starts_with("function ")
339 || line.starts_with("class ")
340 || line.starts_with("const ")
341 || line.starts_with("let ")
342 || line.starts_with("var ")
343 {
344 if !line.contains("export") && !line.contains("module.exports") {
346 break;
347 }
348 }
349 }
350
351 Ok(false)
352 }
353
354 fn is_in_translation_array(file_path: &Path, line_num: usize) -> Result<bool> {
356 let content = std::fs::read_to_string(file_path).map_err(SearchError::Io)?;
357 let lines: Vec<&str> = content.lines().collect();
358
359 if line_num == 0 || line_num > lines.len() {
360 return Ok(false);
361 }
362
363 let target_line_idx = line_num - 1;
364
365 for i in (0..=target_line_idx).rev() {
367 let line = lines[i].trim();
368
369 if line.ends_with('[') || line.contains(": [") {
371 return Self::is_in_translation_context(file_path, i + 1);
373 }
374
375 if line.contains(']') && !line.contains('[') {
377 break;
378 }
379 }
380
381 Ok(false)
382 }
383
384 fn is_multiline_string_continuation(file_path: &Path, line_num: usize) -> Result<bool> {
386 let content = std::fs::read_to_string(file_path).map_err(SearchError::Io)?;
387 let lines: Vec<&str> = content.lines().collect();
388
389 if line_num == 0 || line_num > lines.len() {
390 return Ok(false);
391 }
392
393 let current_line = lines[line_num - 1].trim();
394 let target_line_idx = line_num - 1;
395
396 let has_quotes = current_line.starts_with('\'')
399 || current_line.starts_with('"')
400 || current_line.starts_with('`')
401 || current_line.ends_with('\'')
402 || current_line.ends_with('"')
403 || current_line.ends_with('`');
404
405 let could_be_template_content = !current_line.contains('{')
407 && !current_line.contains('}')
408 && !current_line.contains('[')
409 && !current_line.contains(']')
410 && !current_line.contains(':')
411 && !current_line.contains(';');
412
413 if !has_quotes && !could_be_template_content {
414 return Ok(false);
415 }
416
417 for i in (0..target_line_idx).rev() {
419 let line = lines[i].trim();
420
421 if line.ends_with(" +") || line.ends_with("' +") || line.ends_with("\" +") {
423 if line.contains(':') {
425 return Self::is_in_translation_context(file_path, i + 1);
426 }
427 continue;
429 }
430
431 if line.contains(": `") || line.ends_with("`") || line.starts_with("`") {
433 return Self::is_in_translation_context(file_path, i + 1);
434 }
435
436 if could_be_template_content {
439 for j in (0..i).rev() {
441 let prev_line = lines[j].trim();
442 if prev_line.contains(": `") && !prev_line.ends_with("`") {
443 return Self::is_in_translation_context(file_path, j + 1);
445 }
446 if prev_line.ends_with("`") && !prev_line.contains(": `") {
448 break;
449 }
450 if i - j > 10 {
452 break;
453 }
454 }
455 }
456
457 if line.contains(':')
459 && (line.ends_with('\'') || line.ends_with('"') || line.ends_with('`'))
460 {
461 return Self::is_in_translation_context(file_path, i + 1);
462 }
463
464 if line.contains('{') || line.contains('}') || line.contains('[') || line.contains(']')
466 {
467 if !line.contains(':') {
469 break;
470 }
471 }
472
473 if target_line_idx - i > 5 {
475 break;
476 }
477 }
478
479 Ok(false)
480 }
481
482 pub fn parse_file_with_query(
484 file_path: &Path,
485 query: Option<&str>,
486 ) -> Result<Vec<TranslationEntry>> {
487 if let Some(q) = query {
488 match Self::contains_query(file_path, q) {
490 Ok(false) => return Ok(Vec::new()),
491 Err(_) => {} Ok(true) => {} }
494 }
495
496 let mut entries = Self::parse_file(file_path)?;
497
498 if let Some(q) = query {
499 let q_lower = q.to_lowercase();
500 entries.retain(|e| e.value.to_lowercase().contains(&q_lower));
501 }
502
503 Ok(entries)
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use std::io::Write;
511 use tempfile::NamedTempFile;
512
513 #[test]
514 fn test_parse_es6_export() {
515 let mut file = NamedTempFile::new().unwrap();
516 write!(
517 file,
518 r#"
519export default {{
520 invoice: {{
521 labels: {{
522 add_new: 'Add New',
523 edit: 'Edit Invoice'
524 }}
525 }},
526 user: {{
527 login: 'Log In',
528 logout: 'Log Out'
529 }}
530}};
531"#
532 )
533 .unwrap();
534
535 let entries = JsParser::parse_file(file.path()).unwrap();
536 assert_eq!(entries.len(), 4);
537
538 let keys: Vec<_> = entries.iter().map(|e| e.key.as_str()).collect();
539 assert!(keys.contains(&"invoice.labels.add_new"));
540 assert!(keys.contains(&"invoice.labels.edit"));
541 assert!(keys.contains(&"user.login"));
542 assert!(keys.contains(&"user.logout"));
543
544 let add_new_entry = entries
545 .iter()
546 .find(|e| e.key == "invoice.labels.add_new")
547 .unwrap();
548 assert_eq!(add_new_entry.value, "Add New");
549 }
550
551 #[test]
552 fn test_parse_commonjs_export() {
553 let mut file = NamedTempFile::new().unwrap();
554 write!(
555 file,
556 r#"
557module.exports = {{
558 greeting: {{
559 hello: "Hello World",
560 goodbye: "Goodbye"
561 }}
562}};
563"#
564 )
565 .unwrap();
566
567 let entries = JsParser::parse_file(file.path()).unwrap();
568 assert_eq!(entries.len(), 2);
569
570 let hello_entry = entries.iter().find(|e| e.key == "greeting.hello").unwrap();
571 assert_eq!(hello_entry.value, "Hello World");
572 }
573
574 #[test]
575 fn test_parse_mixed_quotes() {
576 let mut file = NamedTempFile::new().unwrap();
577 write!(
578 file,
579 r#"
580export default {{
581 mixed: {{
582 single: 'Single quotes',
583 double: "Double quotes",
584 unquoted_key: 'value'
585 }}
586}};
587"#
588 )
589 .unwrap();
590
591 let entries = JsParser::parse_file(file.path()).unwrap();
592 assert_eq!(entries.len(), 3);
593
594 let single_entry = entries.iter().find(|e| e.key == "mixed.single").unwrap();
595 assert_eq!(single_entry.value, "Single quotes");
596
597 let unquoted_entry = entries
598 .iter()
599 .find(|e| e.key == "mixed.unquoted_key")
600 .unwrap();
601 assert_eq!(unquoted_entry.value, "value");
602 }
603
604 #[test]
605 fn test_parse_file_with_query() {
606 let mut file = NamedTempFile::new().unwrap();
607 write!(
608 file,
609 r#"
610export default {{
611 test: {{
612 found: 'This should be found',
613 other: 'Other text'
614 }}
615}};
616"#
617 )
618 .unwrap();
619
620 let entries = JsParser::parse_file_with_query(file.path(), Some("found")).unwrap();
622 assert!(!entries.is_empty());
623
624 let entries = JsParser::parse_file_with_query(file.path(), Some("nonexistent")).unwrap();
626 assert!(entries.is_empty());
627 }
628
629 #[test]
630 fn test_extract_object_literal() {
631 let content = r#"
632const something = 'before';
633export default {{
634 key: 'value'
635}};
636const after = 'after';
637"#;
638
639 let result = JsParser::extract_object_literal(content).unwrap();
640 assert!(result.contains("key: 'value'"));
641 assert!(!result.contains("const something"));
642 assert!(!result.contains("const after"));
643 }
644
645 #[test]
646 fn test_js_to_json() {
647 let js = r#"{
648 unquoted: 'single quotes',
649 "already_quoted": "double quotes",
650 nested: {
651 key: 'value'
652 }
653}"#;
654
655 let json = JsParser::js_to_json(js).unwrap();
656
657 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
659 assert!(parsed.is_object());
660 }
661
662 #[test]
663 fn test_contains_query_with_refined_detection() {
664 let mut file = NamedTempFile::new().unwrap();
665 write!(
666 file,
667 r#"
668export default {{
669 el: {{
670 table: {{
671 emptyText: 'No Data',
672 confirmFilter: 'Confirm'
673 }},
674 months: [
675 'January',
676 'February',
677 'March'
678 ],
679 pagination: {{
680 total: 'Total {{total}}'
681 }}
682 }}
683}};
684"#
685 )
686 .unwrap();
687
688 let result = JsParser::contains_query(file.path(), "No Data").unwrap();
690 assert!(result, "Should detect 'No Data' as translation value");
691
692 let result = JsParser::contains_query(file.path(), "Confirm").unwrap();
693 assert!(result, "Should detect 'Confirm' as translation value");
694
695 let result = JsParser::contains_query(file.path(), "January").unwrap();
697 assert!(result, "Should detect 'January' in translation array");
698
699 let result = JsParser::contains_query(file.path(), "March").unwrap();
700 assert!(result, "Should detect 'March' in translation array");
701
702 let result = JsParser::contains_query(file.path(), "emptyText").unwrap();
704 assert!(result, "Should detect 'emptyText' as translation key");
705
706 let result = JsParser::contains_query(file.path(), "NonExistent").unwrap();
708 assert!(!result, "Should not find non-existent content");
709 }
710
711 #[test]
712 fn test_is_translation_value_detection() {
713 let mut file = NamedTempFile::new().unwrap();
715 write!(
716 file,
717 r#"
718export default {{
719 el: {{
720 table: {{
721 emptyText: 'No Data'
722 }}
723 }}
724}};
725"#
726 )
727 .unwrap();
728
729 let result = JsParser::is_translation_value(file.path(), 5, 18, "No Data").unwrap();
731 assert!(result, "Should detect 'No Data' as translation value");
732
733 let mut array_file = NamedTempFile::new().unwrap();
735 write!(
736 array_file,
737 r#"
738export default {{
739 months: [
740 'January',
741 'February'
742 ]
743}};
744"#
745 )
746 .unwrap();
747
748 let result = JsParser::is_translation_value(array_file.path(), 4, 5, "January").unwrap();
750 assert!(result, "Should detect 'January' in translation array");
751
752 let mut non_translation = NamedTempFile::new().unwrap();
754 write!(
755 non_translation,
756 r#"
757const message = 'No Data';
758console.log(message);
759"#
760 )
761 .unwrap();
762
763 let result =
764 JsParser::is_translation_value(non_translation.path(), 2, 17, "No Data").unwrap();
765 assert!(!result, "Should not detect regular variable as translation");
766 }
767
768 #[test]
769 fn test_complex_translation_patterns() {
770 let mut complex_file = NamedTempFile::new().unwrap();
771 write!(
772 complex_file,
773 r#"
774// Some comment with 'No Data' - should not match
775const helper = 'utility function';
776
777export default {{
778 // Translation keys
779 messages: {{
780 error: 'An error occurred',
781 success: 'Operation completed'
782 }},
783
784 // Array of options
785 weekdays: [
786 'Monday',
787 'Tuesday',
788 'Wednesday'
789 ],
790
791 // Multi-line strings
792 description: 'This is a long description that ' +
793 'spans multiple lines',
794
795 // Template literals
796 greeting: `Hello ${{name}}`,
797
798 // Nested structures
799 forms: {{
800 validation: {{
801 required: 'This field is required',
802 email: 'Invalid email format'
803 }}
804 }}
805}};
806
807// Another comment with 'Monday' - should not match
808const otherVar = 'Tuesday';
809"#
810 )
811 .unwrap();
812
813 assert!(JsParser::contains_query(complex_file.path(), "An error occurred").unwrap());
815 assert!(JsParser::contains_query(complex_file.path(), "Monday").unwrap());
816 assert!(JsParser::contains_query(complex_file.path(), "Tuesday").unwrap());
817 assert!(JsParser::contains_query(complex_file.path(), "This field is required").unwrap());
818
819 assert!(JsParser::contains_query(complex_file.path(), "spans multiple lines").unwrap());
821
822 }
826
827 #[test]
828 fn test_multiline_string_detection() {
829 let mut multiline_file = NamedTempFile::new().unwrap();
830 write!(
831 multiline_file,
832 r#"
833export default {{
834 // String concatenation with +
835 longMessage: 'This is the first part ' +
836 'and this is the second part',
837
838 // Template literal multi-line
839 description: `This is a template literal
840 that spans multiple lines
841 with proper indentation`,
842
843 // Complex concatenation
844 complexText: 'Start of text ' +
845 'middle part with details ' +
846 'end of the message',
847
848 // Single line for comparison
849 simple: 'Just a simple message'
850}};
851
852// Non-translation multi-line (should not match)
853const regularVar = 'This is not ' +
854 'a translation string';
855"#
856 )
857 .unwrap();
858
859 assert!(JsParser::contains_query(multiline_file.path(), "first part").unwrap());
861 assert!(JsParser::contains_query(multiline_file.path(), "second part").unwrap());
862
863 assert!(JsParser::contains_query(multiline_file.path(), "template literal").unwrap());
865 assert!(JsParser::contains_query(multiline_file.path(), "spans multiple lines").unwrap());
866 assert!(JsParser::contains_query(multiline_file.path(), "proper indentation").unwrap());
867
868 assert!(JsParser::contains_query(multiline_file.path(), "middle part").unwrap());
870 assert!(JsParser::contains_query(multiline_file.path(), "end of the message").unwrap());
871
872 assert!(JsParser::contains_query(multiline_file.path(), "simple message").unwrap());
874
875 }
879}