cs/parse/
json_parser.rs

1use crate::error::{Result, SearchError};
2use serde_json::Value;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use super::translation::TranslationEntry;
7
8/// Parser for JSON translation files
9pub struct JsonParser;
10
11impl JsonParser {
12    pub fn parse_file(path: &Path) -> Result<Vec<TranslationEntry>> {
13        Self::parse_file_with_query(path, None)
14    }
15
16    /// Parse JSON file, optionally filtering by query for better performance.
17    /// If query is provided, uses bottom-up approach: finds exact matches with grep,
18    /// then traces keys upward WITHOUT parsing the entire JSON structure.
19    pub fn parse_file_with_query(
20        path: &Path,
21        query: Option<&str>,
22    ) -> Result<Vec<TranslationEntry>> {
23        let content = fs::read_to_string(path).map_err(|e| {
24            SearchError::json_parse_error(path, format!("Failed to read file: {}", e))
25        })?;
26
27        // Strip comments to support JSONC (JSON with Comments) format
28        let cleaned_content = Self::strip_json_comments(&content);
29
30        // Parse entire file
31        let root: Value = serde_json::from_str(&cleaned_content).map_err(|e| {
32            SearchError::json_parse_error(path, format!("Invalid JSON syntax: {}", e))
33        })?;
34
35        let mut entries = Vec::new();
36        Self::flatten_json(&root, String::new(), path, &mut entries);
37
38        // Filter by query if provided (since bottom-up trace is disabled)
39        if let Some(q) = query {
40            let q_lower = q.to_lowercase();
41            entries.retain(|e| e.value.to_lowercase().contains(&q_lower));
42        }
43
44        Ok(entries)
45    }
46
47    /// Strip single-line (//) and multi-line (/* */) comments from JSON
48    /// This enables parsing of JSONC (JSON with Comments) files
49    fn strip_json_comments(content: &str) -> String {
50        let mut result = String::with_capacity(content.len());
51        let mut chars = content.chars().peekable();
52        let mut in_string = false;
53        let mut escape_next = false;
54
55        while let Some(ch) = chars.next() {
56            if escape_next {
57                result.push(ch);
58                escape_next = false;
59                continue;
60            }
61
62            if ch == '\\' && in_string {
63                result.push(ch);
64                escape_next = true;
65                continue;
66            }
67
68            if ch == '"' {
69                in_string = !in_string;
70                result.push(ch);
71                continue;
72            }
73
74            if !in_string && ch == '/' {
75                if let Some(&next_ch) = chars.peek() {
76                    if next_ch == '/' {
77                        // Single-line comment - skip until newline
78                        chars.next(); // consume second '/'
79                        for c in chars.by_ref() {
80                            if c == '\n' {
81                                result.push('\n'); // preserve newline for line counting
82                                break;
83                            }
84                        }
85                        continue;
86                    } else if next_ch == '*' {
87                        // Multi-line comment - skip until */
88                        chars.next(); // consume '*'
89                        let mut prev = ' ';
90                        for c in chars.by_ref() {
91                            if prev == '*' && c == '/' {
92                                break;
93                            }
94                            if c == '\n' {
95                                result.push('\n'); // preserve newlines
96                            }
97                            prev = c;
98                        }
99                        continue;
100                    }
101                }
102            }
103
104            result.push(ch);
105        }
106
107        result
108    }
109
110    fn flatten_json(
111        value: &Value,
112        prefix: String,
113        file_path: &Path,
114        entries: &mut Vec<TranslationEntry>,
115    ) {
116        match value {
117            Value::Object(map) => {
118                for (key, val) in map {
119                    let new_prefix = if prefix.is_empty() {
120                        key.clone()
121                    } else {
122                        format!("{}.{}", prefix, key)
123                    };
124
125                    Self::flatten_json(val, new_prefix, file_path, entries);
126                }
127            }
128            Value::String(s) => {
129                entries.push(TranslationEntry {
130                    key: prefix,
131                    value: s.clone(),
132                    line: 0, // Placeholder - serde_json doesn't provide line numbers
133                    file: PathBuf::from(file_path),
134                });
135            }
136            Value::Number(n) => {
137                entries.push(TranslationEntry {
138                    key: prefix,
139                    value: n.to_string(),
140                    line: 0,
141                    file: PathBuf::from(file_path),
142                });
143            }
144            Value::Bool(b) => {
145                entries.push(TranslationEntry {
146                    key: prefix,
147                    value: b.to_string(),
148                    line: 0,
149                    file: PathBuf::from(file_path),
150                });
151            }
152            Value::Array(arr) => {
153                for (index, val) in arr.iter().enumerate() {
154                    let new_prefix = if prefix.is_empty() {
155                        index.to_string()
156                    } else {
157                        format!("{}.{}", prefix, index)
158                    };
159                    Self::flatten_json(val, new_prefix, file_path, entries);
160                }
161            }
162            _ => {
163                // Ignore nulls for now
164            }
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::io::Write;
173    use tempfile::NamedTempFile;
174
175    #[test]
176    fn test_parse_simple_json() {
177        let mut file = NamedTempFile::new().unwrap();
178        write!(file, r#"{{"key": "value"}}"#).unwrap();
179
180        let entries = JsonParser::parse_file(file.path()).unwrap();
181        assert_eq!(entries.len(), 1);
182        assert_eq!(entries[0].key, "key");
183        assert_eq!(entries[0].value, "value");
184    }
185
186    #[test]
187    fn test_parse_nested_json() {
188        let mut file = NamedTempFile::new().unwrap();
189        write!(file, r#"{{"parent": {{"child": "value"}}}}"#).unwrap();
190
191        let entries = JsonParser::parse_file(file.path()).unwrap();
192        assert_eq!(entries.len(), 1);
193        assert_eq!(entries[0].key, "parent.child");
194        assert_eq!(entries[0].value, "value");
195    }
196
197    #[test]
198    fn test_parse_json_array() {
199        let mut file = NamedTempFile::new().unwrap();
200        write!(file, r#"{{"list": ["item1", "item2"]}}"#).unwrap();
201
202        let entries = JsonParser::parse_file(file.path()).unwrap();
203        assert_eq!(entries.len(), 2);
204
205        // Check first item
206        let item1 = entries.iter().find(|e| e.value == "item1").unwrap();
207        assert_eq!(item1.key, "list.0");
208
209        // Check second item
210        let item2 = entries.iter().find(|e| e.value == "item2").unwrap();
211        assert_eq!(item2.key, "list.1");
212    }
213
214    #[test]
215    fn test_bottom_up_trace_json() {
216        let mut file = NamedTempFile::new().unwrap();
217        write!(
218            file,
219            r#"{{
220  "user": {{
221    "authentication": {{
222      "login": "Log In",
223      "logout": "Log Out"
224    }}
225  }}
226}}"#
227        )
228        .unwrap();
229
230        let entries = JsonParser::parse_file_with_query(file.path(), Some("Log In")).unwrap();
231        assert_eq!(entries.len(), 1);
232        assert_eq!(entries[0].value, "Log In");
233        // Key should be traced bottom-up
234        assert!(entries[0].key.contains("login"));
235    }
236}