es_fluent_cli/ftl/
parse.rs1use anyhow::Result;
7use fluent_syntax::{ast, parser};
8use std::collections::HashSet;
9use std::fs;
10use std::path::Path;
11
12pub fn parse_ftl_file(ftl_path: &Path) -> Result<ast::Resource<String>> {
17 if !ftl_path.exists() {
18 return Ok(ast::Resource { body: Vec::new() });
19 }
20
21 let content = fs::read_to_string(ftl_path)?;
22
23 if content.trim().is_empty() {
24 return Ok(ast::Resource { body: Vec::new() });
25 }
26
27 match parser::parse(content) {
28 Ok(res) => Ok(res),
29 Err((res, _)) => Ok(res), }
31}
32
33pub fn extract_message_keys(resource: &ast::Resource<String>) -> HashSet<String> {
35 resource
36 .body
37 .iter()
38 .filter_map(|entry| {
39 if let ast::Entry::Message(msg) = entry {
40 Some(msg.id.name.clone())
41 } else {
42 None
43 }
44 })
45 .collect()
46}
47
48pub fn extract_variables_from_message(msg: &ast::Message<String>) -> HashSet<String> {
50 let mut variables = HashSet::new();
51 if let Some(ref value) = msg.value {
52 extract_variables_from_pattern(value, &mut variables);
53 }
54 for attr in &msg.attributes {
55 extract_variables_from_pattern(&attr.value, &mut variables);
56 }
57 variables
58}
59
60pub fn extract_variables_from_pattern(
62 pattern: &ast::Pattern<String>,
63 variables: &mut HashSet<String>,
64) {
65 for element in &pattern.elements {
66 if let ast::PatternElement::Placeable { expression } = element {
67 extract_variables_from_expression(expression, variables);
68 }
69 }
70}
71
72fn extract_variables_from_expression(
74 expression: &ast::Expression<String>,
75 variables: &mut HashSet<String>,
76) {
77 match expression {
78 ast::Expression::Inline(inline) => {
79 extract_variables_from_inline(inline, variables);
80 },
81 ast::Expression::Select { selector, variants } => {
82 extract_variables_from_inline(selector, variables);
83 for variant in variants {
84 extract_variables_from_pattern(&variant.value, variables);
85 }
86 },
87 }
88}
89
90fn extract_variables_from_inline(
92 inline: &ast::InlineExpression<String>,
93 variables: &mut HashSet<String>,
94) {
95 match inline {
96 ast::InlineExpression::VariableReference { id } => {
97 variables.insert(id.name.clone());
98 },
99 ast::InlineExpression::FunctionReference { arguments, .. } => {
100 for arg in &arguments.positional {
101 extract_variables_from_inline(arg, variables);
102 }
103 for arg in &arguments.named {
104 extract_variables_from_inline(&arg.value, variables);
105 }
106 },
107 ast::InlineExpression::Placeable { expression } => {
108 extract_variables_from_expression(expression, variables);
109 },
110 _ => {},
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use tempfile::tempdir;
118
119 #[test]
120 fn test_parse_ftl_file_nonexistent() {
121 let result = parse_ftl_file(Path::new("/nonexistent/path.ftl")).unwrap();
122 assert!(result.body.is_empty());
123 }
124
125 #[test]
126 fn test_extract_message_keys() {
127 let content = r#"hello = Hello
128world = World"#;
129 let resource = parser::parse(content.to_string()).unwrap();
130 let keys = extract_message_keys(&resource);
131
132 assert!(keys.contains("hello"));
133 assert!(keys.contains("world"));
134 assert_eq!(keys.len(), 2);
135 }
136
137 #[test]
138 fn test_extract_variables() {
139 let content = "hello = Hello { $name }, you have { $count } messages";
140 let resource = parser::parse(content.to_string()).unwrap();
141
142 if let ast::Entry::Message(msg) = &resource.body[0] {
143 let vars = extract_variables_from_message(msg);
144 assert!(vars.contains("name"));
145 assert!(vars.contains("count"));
146 assert_eq!(vars.len(), 2);
147 } else {
148 panic!("Expected a message");
149 }
150 }
151
152 #[test]
153 fn test_extract_variables_from_select() {
154 let content = r#"count = { $num ->
155 [one] One item
156 *[other] { $num } items
157}"#;
158 let resource = parser::parse(content.to_string()).unwrap();
159
160 if let ast::Entry::Message(msg) = &resource.body[0] {
161 let vars = extract_variables_from_message(msg);
162 assert!(vars.contains("num"));
163 } else {
164 panic!("Expected a message");
165 }
166 }
167
168 #[test]
169 fn test_parse_ftl_file_empty_and_partial_parse_recovery() {
170 let temp = tempdir().expect("tempdir");
171 let empty = temp.path().join("empty.ftl");
172 std::fs::write(&empty, " \n").expect("write empty ftl");
173 let empty_res = parse_ftl_file(&empty).expect("parse empty");
174 assert!(empty_res.body.is_empty());
175
176 let invalid = temp.path().join("invalid.ftl");
177 std::fs::write(&invalid, "hello = { $name\nworld = World\n").expect("write invalid ftl");
178 let partial = parse_ftl_file(&invalid).expect("parse invalid");
179 assert!(
180 !partial.body.is_empty(),
181 "invalid FTL should still return partial parse result"
182 );
183 }
184
185 #[test]
186 fn test_parse_ftl_file_errors_when_path_is_directory() {
187 let temp = tempdir().expect("tempdir");
188 let dir_path = temp.path().join("not-a-file");
189 std::fs::create_dir_all(&dir_path).expect("create dir");
190
191 let err = parse_ftl_file(&dir_path).err().expect("expected io error");
192 assert!(
193 err.to_string().contains("Is a directory") || err.to_string().contains("directory")
194 );
195 }
196
197 #[test]
198 fn test_extract_message_keys_ignores_non_message_entries() {
199 let content = "-term = Value\n# Comment\n";
200 let resource = parser::parse(content.to_string()).unwrap();
201 let keys = extract_message_keys(&resource);
202 assert!(keys.is_empty());
203 }
204
205 #[test]
206 fn test_extract_variables_includes_attributes_and_function_arguments() {
207 let content = r#"msg = { FUNC($direct, named: 1) }
208 .attr = Attr { $attr }
209nested = { { $wrapped } }"#;
210 let resource = parser::parse(content.to_string()).unwrap();
211
212 if let ast::Entry::Message(msg) = &resource.body[0] {
213 let vars = extract_variables_from_message(msg);
214 assert!(vars.contains("direct"));
215 assert!(vars.contains("attr"));
216 } else {
217 panic!("Expected a message");
218 }
219
220 if let ast::Entry::Message(msg) = &resource.body[1] {
221 let vars = extract_variables_from_message(msg);
222 assert!(vars.contains("wrapped"));
223 } else {
224 panic!("Expected nested message");
225 }
226 }
227}