es_fluent_cli/commands/
check.rs

1//! Check command for validating FTL files against inventory-registered types.
2//!
3//! This module provides functionality to check FTL files by:
4//! - Running a temp crate that collects inventory registrations (expected keys/variables)
5//! - Parsing FTL files directly using fluent-syntax (for proper ParserError handling)
6//! - Comparing FTL files against the expected keys and variables from Rust code
7//! - Reporting missing keys as errors
8//! - Reporting missing variables as warnings
9
10use crate::commands::{WorkspaceArgs, WorkspaceCrates};
11use crate::core::{
12    CliError, CrateInfo, FtlSyntaxError, MissingKeyError, MissingVariableWarning, ValidationIssue,
13    ValidationReport, find_key_span,
14};
15use crate::ftl::extract_variables_from_message;
16use crate::generation::{prepare_monolithic_runner_crate, run_monolithic};
17use crate::utils::{get_all_locales, ui};
18use anyhow::{Context as _, Result};
19use clap::Parser;
20use es_fluent_toml::I18nConfig;
21use fluent_syntax::ast;
22use fluent_syntax::parser::{self, ParserError};
23use miette::{NamedSource, SourceSpan};
24use regex::Regex;
25use serde::Deserialize;
26use std::collections::{HashMap, HashSet};
27use std::fs;
28use std::path::Path;
29use std::sync::LazyLock;
30
31/// Expected key information from inventory (deserialized from temp crate output).
32#[derive(Deserialize)]
33struct ExpectedKey {
34    key: String,
35    variables: Vec<String>,
36}
37
38/// The inventory data output from the temp crate.
39#[derive(Deserialize)]
40struct InventoryData {
41    expected_keys: Vec<ExpectedKey>,
42}
43
44/// Arguments for the check command.
45#[derive(Debug, Parser)]
46pub struct CheckArgs {
47    #[command(flatten)]
48    pub workspace: WorkspaceArgs,
49
50    /// Check all locales, not just the fallback language.
51    #[arg(long)]
52    pub all: bool,
53}
54
55/// Run the check command.
56pub fn run_check(args: CheckArgs) -> Result<(), CliError> {
57    let workspace = WorkspaceCrates::discover(args.workspace)?;
58
59    if !workspace.print_discovery(ui::print_check_header) {
60        ui::print_no_crates_found();
61        return Ok(());
62    }
63
64    // Prepare monolithic temp crate once for all checks
65    prepare_monolithic_runner_crate(&workspace.workspace_info)
66        .map_err(|e| CliError::Other(e.to_string()))?;
67
68    let mut all_issues: Vec<ValidationIssue> = Vec::new();
69
70    let pb = ui::create_progress_bar(workspace.valid.len() as u64, "Checking crates...");
71
72    for krate in &workspace.valid {
73        pb.set_message(format!("Checking {}", krate.name));
74
75        match check_crate(krate, &workspace.workspace_info, args.all) {
76            Ok(issues) => {
77                all_issues.extend(issues);
78            },
79            Err(e) => {
80                // If error, print above progress bar
81                pb.suspend(|| {
82                    ui::print_check_error(&krate.name, &e.to_string());
83                });
84            },
85        }
86        pb.inc(1);
87    }
88
89    pb.finish_and_clear();
90
91    let error_count = all_issues
92        .iter()
93        .filter(|i| {
94            matches!(
95                i,
96                ValidationIssue::MissingKey(_) | ValidationIssue::SyntaxError(_)
97            )
98        })
99        .count();
100    let warning_count = all_issues
101        .iter()
102        .filter(|i| matches!(i, ValidationIssue::MissingVariable(_)))
103        .count();
104
105    if all_issues.is_empty() {
106        ui::print_check_success();
107        Ok(())
108    } else {
109        Err(CliError::Validation(ValidationReport {
110            error_count,
111            warning_count,
112            issues: all_issues,
113        }))
114    }
115}
116
117use crate::core::WorkspaceInfo;
118
119/// Check a single crate by running the monolithic binary to collect inventory, then validating FTL files.
120fn check_crate(
121    krate: &CrateInfo,
122    workspace: &WorkspaceInfo,
123    check_all: bool,
124) -> Result<Vec<ValidationIssue>> {
125    // Step 1: Get expected keys from inventory via monolithic binary
126    let temp_dir = workspace.root_dir.join(".es-fluent");
127    run_monolithic(workspace, "check", &krate.name, &[])?;
128    let expected_keys = read_inventory_file(&temp_dir, &krate.name)?;
129
130    // Step 2: Parse FTL files and validate against expected keys
131    validate_ftl_files(krate, &expected_keys, check_all)
132}
133
134/// Read inventory data from the generated inventory.json file.
135fn read_inventory_file(
136    temp_dir: &std::path::Path,
137    crate_name: &str,
138) -> Result<HashMap<String, HashSet<String>>> {
139    let inventory_path = temp_dir
140        .join("metadata")
141        .join(crate_name)
142        .join("inventory.json");
143    let json_str = fs::read_to_string(&inventory_path)
144        .with_context(|| format!("Failed to read {}", inventory_path.display()))?;
145
146    let data: InventoryData =
147        serde_json::from_str(&json_str).context("Failed to parse inventory JSON")?;
148
149    // Convert to HashMap for easier lookup
150    let mut expected_keys = HashMap::new();
151    for key_info in data.expected_keys {
152        expected_keys.insert(key_info.key, key_info.variables.into_iter().collect());
153    }
154
155    Ok(expected_keys)
156}
157
158/// Result of loading an FTL file for a locale.
159enum LocaleLoadResult {
160    /// File doesn't exist.
161    NotFound,
162    /// Failed to read the file.
163    ReadError(String),
164    /// Successfully loaded.
165    Loaded {
166        content: String,
167        resource: ast::Resource<String>,
168        parse_errors: Vec<ParserError>,
169    },
170}
171
172/// Load an FTL file for a locale.
173fn load_locale_ftl(assets_dir: &Path, locale: &str, crate_name: &str) -> LocaleLoadResult {
174    let ftl_file = assets_dir.join(locale).join(format!("{}.ftl", crate_name));
175
176    if !ftl_file.exists() {
177        return LocaleLoadResult::NotFound;
178    }
179
180    let content = match fs::read_to_string(&ftl_file) {
181        Ok(c) => c,
182        Err(e) => return LocaleLoadResult::ReadError(e.to_string()),
183    };
184
185    let (resource, parse_errors) = match parser::parse(content.clone()) {
186        Ok(res) => (res, vec![]),
187        Err((res, errors)) => (res, errors),
188    };
189
190    LocaleLoadResult::Loaded {
191        content,
192        resource,
193        parse_errors,
194    }
195}
196
197/// Validate FTL files against expected keys using fluent-syntax directly.
198fn validate_ftl_files(
199    krate: &CrateInfo,
200    expected_keys: &HashMap<String, HashSet<String>>,
201    check_all: bool,
202) -> Result<Vec<ValidationIssue>> {
203    let config = I18nConfig::read_from_path(&krate.i18n_config_path)
204        .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
205
206    let assets_dir = krate.manifest_dir.join(&config.assets_dir);
207
208    let locales: Vec<String> = if check_all {
209        get_all_locales(&assets_dir)?
210    } else {
211        vec![config.fallback_language.clone()]
212    };
213
214    let mut issues = Vec::new();
215
216    for locale in &locales {
217        let file_name = format!("{}/{}.ftl", locale, krate.name);
218
219        match load_locale_ftl(&assets_dir, locale, &krate.name) {
220            LocaleLoadResult::NotFound => {
221                issues.extend(missing_file_issues(expected_keys, locale, &krate.name));
222                continue;
223            },
224            LocaleLoadResult::ReadError(err) => {
225                issues.push(ValidationIssue::SyntaxError(FtlSyntaxError {
226                    src: NamedSource::new(file_name.clone(), String::new()),
227                    span: SourceSpan::new(0_usize.into(), 1_usize),
228                    locale: locale.clone(),
229                    file_name,
230                    help: format!("Failed to read file: {}", err),
231                }));
232                continue;
233            },
234            LocaleLoadResult::Loaded {
235                content,
236                resource,
237                parse_errors,
238            } => {
239                issues.extend(validate_loaded_ftl(
240                    &content,
241                    &resource,
242                    &parse_errors,
243                    expected_keys,
244                    locale,
245                    &file_name,
246                    &krate.name,
247                ));
248            },
249        }
250    }
251
252    Ok(issues)
253}
254
255/// Generate missing key issues when an FTL file doesn't exist.
256fn missing_file_issues(
257    expected_keys: &HashMap<String, HashSet<String>>,
258    locale: &str,
259    crate_name: &str,
260) -> Vec<ValidationIssue> {
261    expected_keys
262        .keys()
263        .map(|key| {
264            ValidationIssue::MissingKey(MissingKeyError {
265                src: NamedSource::new(format!("{}/{}.ftl", locale, crate_name), String::new()),
266                key: key.clone(),
267                locale: locale.to_string(),
268                help: format!(
269                    "Add translation for '{}' in {}/{}.ftl",
270                    key, locale, crate_name
271                ),
272            })
273        })
274        .collect()
275}
276
277/// Validate a loaded FTL file against expected keys.
278fn validate_loaded_ftl(
279    content: &str,
280    resource: &ast::Resource<String>,
281    parse_errors: &[ParserError],
282    expected_keys: &HashMap<String, HashSet<String>>,
283    locale: &str,
284    file_name: &str,
285    crate_name: &str,
286) -> Vec<ValidationIssue> {
287    let mut issues = Vec::new();
288    let mut keys_with_syntax_errors: HashSet<String> = HashSet::new();
289
290    // Convert parse errors to issues
291    for err in parse_errors {
292        issues.push(parser_error_to_issue(
293            err,
294            content,
295            locale,
296            file_name,
297            &mut keys_with_syntax_errors,
298        ));
299    }
300
301    // Scan Junk entries to find keys with errors
302    for entry in &resource.body {
303        if let ast::Entry::Junk { content: junk } = entry
304            && let Some(key) = extract_key_from_junk(junk)
305        {
306            keys_with_syntax_errors.insert(key);
307        }
308    }
309
310    // Build map of actual keys and their variables
311    let actual_keys: HashMap<String, HashSet<String>> = resource
312        .body
313        .iter()
314        .filter_map(|entry| match entry {
315            ast::Entry::Message(msg) => {
316                Some((msg.id.name.clone(), extract_variables_from_message(msg)))
317            },
318            _ => None,
319        })
320        .collect();
321
322    // Check for missing keys and variables
323    for (key, expected_vars) in expected_keys {
324        // Skip keys that have syntax errors - they're already reported
325        if keys_with_syntax_errors.contains(key) {
326            continue;
327        }
328
329        let Some(actual_vars) = actual_keys.get(key) else {
330            // Key is missing
331            issues.push(ValidationIssue::MissingKey(MissingKeyError {
332                src: NamedSource::new(file_name, content.to_string()),
333                key: key.clone(),
334                locale: locale.to_string(),
335                help: format!(
336                    "Add translation for '{}' in {}/{}.ftl",
337                    key, locale, crate_name
338                ),
339            }));
340            continue;
341        };
342
343        // Check for missing variables
344        for var in expected_vars {
345            if actual_vars.contains(var) {
346                continue;
347            }
348            let span = find_key_span(content, key)
349                .unwrap_or_else(|| SourceSpan::new(0_usize.into(), 1_usize));
350
351            issues.push(ValidationIssue::MissingVariable(MissingVariableWarning {
352                src: NamedSource::new(file_name, content.to_string()),
353                span,
354                variable: var.clone(),
355                key: key.clone(),
356                locale: locale.to_string(),
357                help: format!(
358                    "The Rust code generated by es-fluent declares variable '${}' but the translation omits it",
359                    var
360                ),
361            }));
362        }
363    }
364
365    issues
366}
367
368/// Convert a fluent-syntax ParserError to a ValidationIssue.
369fn parser_error_to_issue(
370    err: &ParserError,
371    content: &str,
372    locale: &str,
373    file_name: &str,
374    keys_with_syntax_errors: &mut HashSet<String>,
375) -> ValidationIssue {
376    // Try to extract message key from the junk slice if available
377    if let Some(ref slice) = err.slice {
378        let junk_content = &content[slice.clone()];
379        if let Some(key) = extract_key_from_junk(junk_content) {
380            keys_with_syntax_errors.insert(key);
381        }
382    }
383
384    // Calculate span from ParserError position
385    let span_len = if err.pos.end > err.pos.start {
386        err.pos.end - err.pos.start
387    } else {
388        1
389    };
390
391    ValidationIssue::SyntaxError(FtlSyntaxError {
392        src: NamedSource::new(file_name, content.to_string()),
393        span: SourceSpan::new(err.pos.start.into(), span_len),
394        locale: locale.to_string(),
395        file_name: file_name.to_string(),
396        help: err.kind.to_string(),
397    })
398}
399
400/// Try to extract a message key from junk content.
401/// Junk typically starts with the message identifier like "message-key = ..."
402fn extract_key_from_junk(junk: &str) -> Option<String> {
403    static KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+").unwrap());
404
405    KEY_REGEX
406        .find(junk.trim_start())
407        .map(|m| m.as_str().to_string())
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_find_key_span() {
416        let source = "## Comment\nhello = Hello\nworld = World";
417        let span = find_key_span(source, "hello").unwrap();
418        assert_eq!(span.offset(), 11);
419        assert_eq!(span.len(), 5);
420    }
421
422    #[test]
423    fn test_find_key_span_not_found() {
424        let source = "hello = Hello";
425        let span = find_key_span(source, "goodbye");
426        assert!(span.is_none());
427    }
428
429    #[test]
430    fn test_extract_key_from_junk() {
431        assert_eq!(
432            extract_key_from_junk("my-key = some value"),
433            Some("my-key".to_string())
434        );
435        assert_eq!(
436            extract_key_from_junk("  spaced-key = value"),
437            Some("spaced-key".to_string())
438        );
439        assert_eq!(extract_key_from_junk("# comment"), None);
440        assert_eq!(extract_key_from_junk(""), None);
441    }
442
443    #[test]
444    fn test_extract_variables() {
445        let content = "hello = Hello { $name }, you have { $count } messages";
446        let resource = parser::parse(content.to_string()).unwrap();
447
448        if let ast::Entry::Message(msg) = &resource.body[0] {
449            let vars = extract_variables_from_message(msg);
450            assert!(vars.contains("name"));
451            assert!(vars.contains("count"));
452            assert_eq!(vars.len(), 2);
453        } else {
454            panic!("Expected a message");
455        }
456    }
457}