use crate::error::{Result, SearchError};
use crate::parse::translation::TranslationEntry;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
pub struct JsParser;
impl JsParser {
pub fn parse_file(file_path: &Path) -> Result<Vec<TranslationEntry>> {
let content = fs::read_to_string(file_path).map_err(SearchError::Io)?;
Self::parse_content(&content, file_path)
}
pub fn parse_content(content: &str, file_path: &Path) -> Result<Vec<TranslationEntry>> {
let object_content = Self::extract_object_literal(content)?;
let parsed_object = Self::parse_object_literal(&object_content)?;
let mut entries = Vec::new();
Self::flatten_object(&parsed_object, String::new(), file_path, &mut entries);
Ok(entries)
}
fn extract_object_literal(content: &str) -> Result<String> {
let content = content.trim();
let start_patterns = ["export default", "module.exports =", "exports ="];
let mut object_start = None;
for pattern in &start_patterns {
if let Some(pos) = content.find(pattern) {
let after_pattern = &content[pos + pattern.len()..];
if let Some(brace_pos) = after_pattern.find('{') {
object_start = Some(pos + pattern.len() + brace_pos);
break;
}
}
}
let start = object_start
.ok_or_else(|| SearchError::Generic("No JavaScript object export found".to_string()))?;
let mut brace_count = 0;
let mut end = start;
let chars: Vec<char> = content.chars().collect();
for (i, &ch) in chars.iter().enumerate().skip(start) {
match ch {
'{' => brace_count += 1,
'}' => {
brace_count -= 1;
if brace_count == 0 {
end = i + 1;
break;
}
}
_ => {}
}
}
if brace_count != 0 {
return Err(SearchError::Generic(
"Unmatched braces in JavaScript object".to_string(),
));
}
Ok(content[start..end].to_string())
}
fn parse_object_literal(content: &str) -> Result<HashMap<String, serde_json::Value>> {
let json_content = Self::js_to_json(content)?;
serde_json::from_str(&json_content)
.map_err(|e| SearchError::Generic(format!("Failed to parse JavaScript object: {}", e)))
}
fn js_to_json(js_content: &str) -> Result<String> {
let mut result = String::new();
let chars: Vec<char> = js_content.chars().collect();
let mut i = 0;
let mut in_string = false;
let mut string_char = '"';
while i < chars.len() {
let ch = chars[i];
match ch {
'"' | '\'' => {
if !in_string {
in_string = true;
string_char = ch;
result.push('"'); } else if ch == string_char {
in_string = false;
result.push('"');
} else {
result.push(ch);
}
}
_ if in_string => {
result.push(ch);
}
_ if (ch.is_alphabetic() || ch == '_') && !in_string => {
let mut j = i;
let mut prop_name = String::new();
while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
prop_name.push(chars[j]);
j += 1;
}
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j < chars.len() && chars[j] == ':' {
result.push('"');
result.push_str(&prop_name);
result.push('"');
i = j - 1; } else {
result.push(ch);
}
}
_ => {
result.push(ch);
}
}
i += 1;
}
Ok(result)
}
fn flatten_object(
obj: &HashMap<String, serde_json::Value>,
prefix: String,
file_path: &Path,
entries: &mut Vec<TranslationEntry>,
) {
for (key, value) in obj {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
match value {
serde_json::Value::String(s) => {
entries.push(TranslationEntry {
key: full_key,
value: s.clone(),
file: file_path.to_path_buf(),
line: 1, });
}
serde_json::Value::Object(nested_obj) => {
let nested_map: HashMap<String, serde_json::Value> = nested_obj
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Self::flatten_object(&nested_map, full_key, file_path, entries);
}
serde_json::Value::Array(arr) => {
for (i, v) in arr.iter().enumerate() {
let item_key = format!("{}.{}", full_key, i);
if let serde_json::Value::String(s) = v {
entries.push(TranslationEntry {
key: item_key,
value: s.clone(),
file: file_path.to_path_buf(),
line: 1,
});
} else if let serde_json::Value::Object(nested_obj) = v {
let nested_map: HashMap<String, serde_json::Value> = nested_obj
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Self::flatten_object(&nested_map, item_key, file_path, entries);
}
}
}
_ => {
}
}
}
}
pub fn contains_query(file_path: &Path, query: &str) -> Result<bool> {
use grep_matcher::Matcher;
use grep_regex::RegexMatcherBuilder;
use grep_searcher::{sinks::UTF8, SearcherBuilder};
let matcher = RegexMatcherBuilder::new()
.case_insensitive(true)
.fixed_strings(true)
.build(query)
.map_err(|e| SearchError::Generic(format!("Failed to build matcher: {}", e)))?;
let mut searcher = SearcherBuilder::new().line_number(true).build();
let mut found = false;
let _ = searcher.search_path(
&matcher,
file_path,
UTF8(|line_num, line| {
let mut stop = false;
let _ = matcher.find_iter(line.as_bytes(), |m| {
let col_num = m.start() + 1;
if let Ok(true) =
Self::is_translation_value(file_path, line_num as usize, col_num, query)
{
found = true;
stop = true;
return false; }
true });
if stop {
Ok(false) } else {
Ok(true) }
}),
);
Ok(found)
}
fn is_translation_value(
file_path: &Path,
line_num: usize,
col_num: usize,
_query: &str,
) -> Result<bool> {
let content = std::fs::read_to_string(file_path).map_err(SearchError::Io)?;
let lines: Vec<&str> = content.lines().collect();
if line_num == 0 || line_num > lines.len() {
return Ok(false);
}
let line = lines[line_num - 1]; let match_start = col_num - 1;
if let Some(colon_pos) = line.find(':') {
if match_start > colon_pos {
return Self::is_in_translation_context(file_path, line_num);
}
}
if !line.contains(':') {
if Self::is_in_translation_array(file_path, line_num)? {
return Ok(true);
}
return Self::is_multiline_string_continuation(file_path, line_num);
}
if line.contains(':') && match_start < line.find(':').unwrap_or(0) {
return Self::is_in_translation_context(file_path, line_num);
}
Ok(false)
}
fn is_in_translation_context(file_path: &Path, line_num: usize) -> Result<bool> {
let content = std::fs::read_to_string(file_path).map_err(SearchError::Io)?;
let lines: Vec<&str> = content.lines().collect();
if line_num == 0 || line_num > lines.len() {
return Ok(false);
}
let target_line_idx = line_num - 1;
for i in (0..=target_line_idx).rev() {
let line = lines[i].trim();
if line.contains("export default") || line.contains("module.exports") {
return Ok(true);
}
if line.starts_with("function ")
|| line.starts_with("class ")
|| line.starts_with("const ")
|| line.starts_with("let ")
|| line.starts_with("var ")
{
if !line.contains("export") && !line.contains("module.exports") {
break;
}
}
}
Ok(false)
}
fn is_in_translation_array(file_path: &Path, line_num: usize) -> Result<bool> {
let content = std::fs::read_to_string(file_path).map_err(SearchError::Io)?;
let lines: Vec<&str> = content.lines().collect();
if line_num == 0 || line_num > lines.len() {
return Ok(false);
}
let target_line_idx = line_num - 1;
for i in (0..=target_line_idx).rev() {
let line = lines[i].trim();
if line.ends_with('[') || line.contains(": [") {
return Self::is_in_translation_context(file_path, i + 1);
}
if line.contains(']') && !line.contains('[') {
break;
}
}
Ok(false)
}
fn is_multiline_string_continuation(file_path: &Path, line_num: usize) -> Result<bool> {
let content = std::fs::read_to_string(file_path).map_err(SearchError::Io)?;
let lines: Vec<&str> = content.lines().collect();
if line_num == 0 || line_num > lines.len() {
return Ok(false);
}
let current_line = lines[line_num - 1].trim();
let target_line_idx = line_num - 1;
let has_quotes = current_line.starts_with('\'')
|| current_line.starts_with('"')
|| current_line.starts_with('`')
|| current_line.ends_with('\'')
|| current_line.ends_with('"')
|| current_line.ends_with('`');
let could_be_template_content = !current_line.contains('{')
&& !current_line.contains('}')
&& !current_line.contains('[')
&& !current_line.contains(']')
&& !current_line.contains(':')
&& !current_line.contains(';');
if !has_quotes && !could_be_template_content {
return Ok(false);
}
for i in (0..target_line_idx).rev() {
let line = lines[i].trim();
if line.ends_with(" +") || line.ends_with("' +") || line.ends_with("\" +") {
if line.contains(':') {
return Self::is_in_translation_context(file_path, i + 1);
}
continue;
}
if line.contains(": `") || line.ends_with("`") || line.starts_with("`") {
return Self::is_in_translation_context(file_path, i + 1);
}
if could_be_template_content {
for j in (0..i).rev() {
let prev_line = lines[j].trim();
if prev_line.contains(": `") && !prev_line.ends_with("`") {
return Self::is_in_translation_context(file_path, j + 1);
}
if prev_line.ends_with("`") && !prev_line.contains(": `") {
break;
}
if i - j > 10 {
break;
}
}
}
if line.contains(':')
&& (line.ends_with('\'') || line.ends_with('"') || line.ends_with('`'))
{
return Self::is_in_translation_context(file_path, i + 1);
}
if line.contains('{') || line.contains('}') || line.contains('[') || line.contains(']')
{
if !line.contains(':') {
break;
}
}
if target_line_idx - i > 5 {
break;
}
}
Ok(false)
}
pub fn parse_file_with_query(
file_path: &Path,
query: Option<&str>,
) -> Result<Vec<TranslationEntry>> {
if let Some(q) = query {
match Self::contains_query(file_path, q) {
Ok(false) => return Ok(Vec::new()),
Err(_) => {} Ok(true) => {} }
}
let mut entries = Self::parse_file(file_path)?;
if let Some(q) = query {
let q_lower = q.to_lowercase();
entries.retain(|e| e.value.to_lowercase().contains(&q_lower));
}
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_parse_es6_export() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
r#"
export default {{
invoice: {{
labels: {{
add_new: 'Add New',
edit: 'Edit Invoice'
}}
}},
user: {{
login: 'Log In',
logout: 'Log Out'
}}
}};
"#
)
.unwrap();
let entries = JsParser::parse_file(file.path()).unwrap();
assert_eq!(entries.len(), 4);
let keys: Vec<_> = entries.iter().map(|e| e.key.as_str()).collect();
assert!(keys.contains(&"invoice.labels.add_new"));
assert!(keys.contains(&"invoice.labels.edit"));
assert!(keys.contains(&"user.login"));
assert!(keys.contains(&"user.logout"));
let add_new_entry = entries
.iter()
.find(|e| e.key == "invoice.labels.add_new")
.unwrap();
assert_eq!(add_new_entry.value, "Add New");
}
#[test]
fn test_parse_commonjs_export() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
r#"
module.exports = {{
greeting: {{
hello: "Hello World",
goodbye: "Goodbye"
}}
}};
"#
)
.unwrap();
let entries = JsParser::parse_file(file.path()).unwrap();
assert_eq!(entries.len(), 2);
let hello_entry = entries.iter().find(|e| e.key == "greeting.hello").unwrap();
assert_eq!(hello_entry.value, "Hello World");
}
#[test]
fn test_parse_mixed_quotes() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
r#"
export default {{
mixed: {{
single: 'Single quotes',
double: "Double quotes",
unquoted_key: 'value'
}}
}};
"#
)
.unwrap();
let entries = JsParser::parse_file(file.path()).unwrap();
assert_eq!(entries.len(), 3);
let single_entry = entries.iter().find(|e| e.key == "mixed.single").unwrap();
assert_eq!(single_entry.value, "Single quotes");
let unquoted_entry = entries
.iter()
.find(|e| e.key == "mixed.unquoted_key")
.unwrap();
assert_eq!(unquoted_entry.value, "value");
}
#[test]
fn test_parse_file_with_query() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
r#"
export default {{
test: {{
found: 'This should be found',
other: 'Other text'
}}
}};
"#
)
.unwrap();
let entries = JsParser::parse_file_with_query(file.path(), Some("found")).unwrap();
assert!(!entries.is_empty());
let entries = JsParser::parse_file_with_query(file.path(), Some("nonexistent")).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_extract_object_literal() {
let content = r#"
const something = 'before';
export default {{
key: 'value'
}};
const after = 'after';
"#;
let result = JsParser::extract_object_literal(content).unwrap();
assert!(result.contains("key: 'value'"));
assert!(!result.contains("const something"));
assert!(!result.contains("const after"));
}
#[test]
fn test_js_to_json() {
let js = r#"{
unquoted: 'single quotes',
"already_quoted": "double quotes",
nested: {
key: 'value'
}
}"#;
let json = JsParser::js_to_json(js).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.is_object());
}
#[test]
fn test_contains_query_with_refined_detection() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
r#"
export default {{
el: {{
table: {{
emptyText: 'No Data',
confirmFilter: 'Confirm'
}},
months: [
'January',
'February',
'March'
],
pagination: {{
total: 'Total {{total}}'
}}
}}
}};
"#
)
.unwrap();
let result = JsParser::contains_query(file.path(), "No Data").unwrap();
assert!(result, "Should detect 'No Data' as translation value");
let result = JsParser::contains_query(file.path(), "Confirm").unwrap();
assert!(result, "Should detect 'Confirm' as translation value");
let result = JsParser::contains_query(file.path(), "January").unwrap();
assert!(result, "Should detect 'January' in translation array");
let result = JsParser::contains_query(file.path(), "March").unwrap();
assert!(result, "Should detect 'March' in translation array");
let result = JsParser::contains_query(file.path(), "emptyText").unwrap();
assert!(result, "Should detect 'emptyText' as translation key");
let result = JsParser::contains_query(file.path(), "NonExistent").unwrap();
assert!(!result, "Should not find non-existent content");
}
#[test]
fn test_is_translation_value_detection() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
r#"
export default {{
el: {{
table: {{
emptyText: 'No Data'
}}
}}
}};
"#
)
.unwrap();
let result = JsParser::is_translation_value(file.path(), 5, 18, "No Data").unwrap();
assert!(result, "Should detect 'No Data' as translation value");
let mut array_file = NamedTempFile::new().unwrap();
write!(
array_file,
r#"
export default {{
months: [
'January',
'February'
]
}};
"#
)
.unwrap();
let result = JsParser::is_translation_value(array_file.path(), 4, 5, "January").unwrap();
assert!(result, "Should detect 'January' in translation array");
let mut non_translation = NamedTempFile::new().unwrap();
write!(
non_translation,
r#"
const message = 'No Data';
console.log(message);
"#
)
.unwrap();
let result =
JsParser::is_translation_value(non_translation.path(), 2, 17, "No Data").unwrap();
assert!(!result, "Should not detect regular variable as translation");
}
#[test]
fn test_complex_translation_patterns() {
let mut complex_file = NamedTempFile::new().unwrap();
write!(
complex_file,
r#"
// Some comment with 'No Data' - should not match
const helper = 'utility function';
export default {{
// Translation keys
messages: {{
error: 'An error occurred',
success: 'Operation completed'
}},
// Array of options
weekdays: [
'Monday',
'Tuesday',
'Wednesday'
],
// Multi-line strings
description: 'This is a long description that ' +
'spans multiple lines',
// Template literals
greeting: `Hello ${{name}}`,
// Nested structures
forms: {{
validation: {{
required: 'This field is required',
email: 'Invalid email format'
}}
}}
}};
// Another comment with 'Monday' - should not match
const otherVar = 'Tuesday';
"#
)
.unwrap();
assert!(JsParser::contains_query(complex_file.path(), "An error occurred").unwrap());
assert!(JsParser::contains_query(complex_file.path(), "Monday").unwrap());
assert!(JsParser::contains_query(complex_file.path(), "Tuesday").unwrap());
assert!(JsParser::contains_query(complex_file.path(), "This field is required").unwrap());
assert!(JsParser::contains_query(complex_file.path(), "spans multiple lines").unwrap());
}
#[test]
fn test_multiline_string_detection() {
let mut multiline_file = NamedTempFile::new().unwrap();
write!(
multiline_file,
r#"
export default {{
// String concatenation with +
longMessage: 'This is the first part ' +
'and this is the second part',
// Template literal multi-line
description: `This is a template literal
that spans multiple lines
with proper indentation`,
// Complex concatenation
complexText: 'Start of text ' +
'middle part with details ' +
'end of the message',
// Single line for comparison
simple: 'Just a simple message'
}};
// Non-translation multi-line (should not match)
const regularVar = 'This is not ' +
'a translation string';
"#
)
.unwrap();
assert!(JsParser::contains_query(multiline_file.path(), "first part").unwrap());
assert!(JsParser::contains_query(multiline_file.path(), "second part").unwrap());
assert!(JsParser::contains_query(multiline_file.path(), "template literal").unwrap());
assert!(JsParser::contains_query(multiline_file.path(), "spans multiple lines").unwrap());
assert!(JsParser::contains_query(multiline_file.path(), "proper indentation").unwrap());
assert!(JsParser::contains_query(multiline_file.path(), "middle part").unwrap());
assert!(JsParser::contains_query(multiline_file.path(), "end of the message").unwrap());
assert!(JsParser::contains_query(multiline_file.path(), "simple message").unwrap());
}
}