Skip to main content

es_fluent_cli/ftl/
parse.rs

1//! FTL file parsing utilities.
2//!
3//! Provides shared functions for parsing FTL files and extracting
4//! message information.
5
6use anyhow::Result;
7use fluent_syntax::{ast, parser};
8use std::collections::HashSet;
9use std::fs;
10use std::path::Path;
11
12/// Parse an FTL file and return the resource.
13///
14/// Returns an empty resource if the file doesn't exist or is empty.
15/// Uses partial parse recovery for files with syntax errors.
16pub 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), // Use partial result on parse errors
30    }
31}
32
33/// Extract message keys from a resource.
34pub 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
48/// Extract variables from a message.
49pub 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
60/// Extract variables from a pattern.
61pub 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
72/// Extract variables from an expression.
73fn 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
90/// Extract variables from an inline expression.
91fn 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}