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    /// Keys to ignore during validation. Can be specified multiple times
55    /// (e.g., --ignore a --ignore b) or comma-separated (e.g., --ignore a,b).
56    #[arg(long, value_delimiter = ',')]
57    pub ignore: Vec<String>,
58}
59
60/// Run the check command.
61pub fn run_check(args: CheckArgs) -> Result<(), CliError> {
62    let workspace = WorkspaceCrates::discover(args.workspace)?;
63
64    if !workspace.print_discovery(ui::print_check_header) {
65        ui::print_no_crates_found();
66        return Ok(());
67    }
68
69    // Convert ignore list to a HashSet for efficient lookups
70    let ignore_keys: HashSet<String> = args.ignore.into_iter().collect();
71
72    // Prepare monolithic temp crate once for all checks
73    prepare_monolithic_runner_crate(&workspace.workspace_info)
74        .map_err(|e| CliError::Other(e.to_string()))?;
75
76    // First pass: collect all expected keys from all crates to validate ignore list
77    let temp_dir = workspace.workspace_info.root_dir.join(".es-fluent");
78    let mut all_known_keys: HashSet<String> = HashSet::new();
79
80    let pb = ui::create_progress_bar(workspace.valid.len() as u64, "Collecting keys...");
81
82    for krate in &workspace.valid {
83        pb.set_message(format!("Scanning {}", krate.name));
84        run_monolithic(&workspace.workspace_info, "check", &krate.name, &[])
85            .map_err(|e| CliError::Other(e.to_string()))?;
86        if let Ok(expected_keys) = read_inventory_file(&temp_dir, &krate.name) {
87            all_known_keys.extend(expected_keys.into_keys());
88        }
89        pb.inc(1);
90    }
91
92    pb.finish_and_clear();
93
94    // Validate that all ignore keys are known
95    if !ignore_keys.is_empty() {
96        let mut unknown_keys: Vec<&String> = ignore_keys
97            .iter()
98            .filter(|k| !all_known_keys.contains(*k))
99            .collect();
100
101        if !unknown_keys.is_empty() {
102            // Sort for deterministic error messages
103            unknown_keys.sort();
104
105            return Err(CliError::Other(format!(
106                "Unknown keys passed to --ignore: {}",
107                unknown_keys
108                    .iter()
109                    .map(|k| format!("'{}'", k))
110                    .collect::<Vec<_>>()
111                    .join(", ")
112            )));
113        }
114    }
115
116    // Second pass: validate FTL files
117    let mut all_issues: Vec<ValidationIssue> = Vec::new();
118
119    let pb = ui::create_progress_bar(workspace.valid.len() as u64, "Checking crates...");
120
121    for krate in &workspace.valid {
122        pb.set_message(format!("Checking {}", krate.name));
123
124        match validate_crate(krate, &temp_dir, args.all, &ignore_keys) {
125            Ok(issues) => {
126                all_issues.extend(issues);
127            },
128            Err(e) => {
129                // If error, print above progress bar
130                pb.suspend(|| {
131                    ui::print_check_error(&krate.name, &e.to_string());
132                });
133            },
134        }
135        pb.inc(1);
136    }
137
138    pb.finish_and_clear();
139
140    let error_count = all_issues
141        .iter()
142        .filter(|i| {
143            matches!(
144                i,
145                ValidationIssue::MissingKey(_) | ValidationIssue::SyntaxError(_)
146            )
147        })
148        .count();
149    let warning_count = all_issues
150        .iter()
151        .filter(|i| matches!(i, ValidationIssue::MissingVariable(_)))
152        .count();
153
154    if all_issues.is_empty() {
155        ui::print_check_success();
156        Ok(())
157    } else {
158        Err(CliError::Validation(ValidationReport {
159            error_count,
160            warning_count,
161            issues: all_issues,
162        }))
163    }
164}
165
166/// Validate a single crate's FTL files using already-collected inventory data.
167fn validate_crate(
168    krate: &CrateInfo,
169    temp_dir: &Path,
170    check_all: bool,
171    ignore_keys: &HashSet<String>,
172) -> Result<Vec<ValidationIssue>> {
173    // Read the inventory that was already collected in the first pass
174    let mut expected_keys = read_inventory_file(temp_dir, &krate.name)?;
175
176    // Filter out ignored keys
177    for key in ignore_keys {
178        expected_keys.remove(key);
179    }
180
181    // Validate FTL files against expected keys
182    validate_ftl_files(krate, &expected_keys, check_all)
183}
184
185/// Read inventory data from the generated inventory.json file.
186fn read_inventory_file(
187    temp_dir: &std::path::Path,
188    crate_name: &str,
189) -> Result<HashMap<String, HashSet<String>>> {
190    let inventory_path = temp_dir
191        .join("metadata")
192        .join(crate_name)
193        .join("inventory.json");
194    let json_str = fs::read_to_string(&inventory_path)
195        .with_context(|| format!("Failed to read {}", inventory_path.display()))?;
196
197    let data: InventoryData =
198        serde_json::from_str(&json_str).context("Failed to parse inventory JSON")?;
199
200    // Convert to HashMap for easier lookup
201    let mut expected_keys = HashMap::new();
202    for key_info in data.expected_keys {
203        expected_keys.insert(key_info.key, key_info.variables.into_iter().collect());
204    }
205
206    Ok(expected_keys)
207}
208
209/// Result of loading an FTL file for a locale.
210enum LocaleLoadResult {
211    /// File doesn't exist.
212    NotFound,
213    /// Failed to read the file.
214    ReadError(String),
215    /// Successfully loaded.
216    Loaded {
217        content: String,
218        resource: ast::Resource<String>,
219        parse_errors: Vec<ParserError>,
220    },
221}
222
223/// Load an FTL file for a locale.
224fn load_locale_ftl(assets_dir: &Path, locale: &str, crate_name: &str) -> LocaleLoadResult {
225    let ftl_file = assets_dir.join(locale).join(format!("{}.ftl", crate_name));
226
227    if !ftl_file.exists() {
228        return LocaleLoadResult::NotFound;
229    }
230
231    let content = match fs::read_to_string(&ftl_file) {
232        Ok(c) => c,
233        Err(e) => return LocaleLoadResult::ReadError(e.to_string()),
234    };
235
236    let (resource, parse_errors) = match parser::parse(content.clone()) {
237        Ok(res) => (res, vec![]),
238        Err((res, errors)) => (res, errors),
239    };
240
241    LocaleLoadResult::Loaded {
242        content,
243        resource,
244        parse_errors,
245    }
246}
247
248/// Validate FTL files against expected keys using fluent-syntax directly.
249fn validate_ftl_files(
250    krate: &CrateInfo,
251    expected_keys: &HashMap<String, HashSet<String>>,
252    check_all: bool,
253) -> Result<Vec<ValidationIssue>> {
254    let config = I18nConfig::read_from_path(&krate.i18n_config_path)
255        .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
256
257    let assets_dir = krate.manifest_dir.join(&config.assets_dir);
258
259    let locales: Vec<String> = if check_all {
260        get_all_locales(&assets_dir)?
261    } else {
262        vec![config.fallback_language.clone()]
263    };
264
265    let mut issues = Vec::new();
266
267    for locale in &locales {
268        let file_name = format!("{}/{}.ftl", locale, krate.name);
269
270        match load_locale_ftl(&assets_dir, locale, &krate.name) {
271            LocaleLoadResult::NotFound => {
272                issues.extend(missing_file_issues(expected_keys, locale, &krate.name));
273                continue;
274            },
275            LocaleLoadResult::ReadError(err) => {
276                issues.push(ValidationIssue::SyntaxError(FtlSyntaxError {
277                    src: NamedSource::new(file_name.clone(), String::new()),
278                    span: SourceSpan::new(0_usize.into(), 1_usize),
279                    locale: locale.clone(),
280                    file_name,
281                    help: format!("Failed to read file: {}", err),
282                }));
283                continue;
284            },
285            LocaleLoadResult::Loaded {
286                content,
287                resource,
288                parse_errors,
289            } => {
290                issues.extend(validate_loaded_ftl(
291                    &content,
292                    &resource,
293                    &parse_errors,
294                    expected_keys,
295                    locale,
296                    &file_name,
297                    &krate.name,
298                ));
299            },
300        }
301    }
302
303    Ok(issues)
304}
305
306/// Generate missing key issues when an FTL file doesn't exist.
307fn missing_file_issues(
308    expected_keys: &HashMap<String, HashSet<String>>,
309    locale: &str,
310    crate_name: &str,
311) -> Vec<ValidationIssue> {
312    expected_keys
313        .keys()
314        .map(|key| {
315            ValidationIssue::MissingKey(MissingKeyError {
316                src: NamedSource::new(format!("{}/{}.ftl", locale, crate_name), String::new()),
317                key: key.clone(),
318                locale: locale.to_string(),
319                help: format!(
320                    "Add translation for '{}' in {}/{}.ftl",
321                    key, locale, crate_name
322                ),
323            })
324        })
325        .collect()
326}
327
328/// Validate a loaded FTL file against expected keys.
329fn validate_loaded_ftl(
330    content: &str,
331    resource: &ast::Resource<String>,
332    parse_errors: &[ParserError],
333    expected_keys: &HashMap<String, HashSet<String>>,
334    locale: &str,
335    file_name: &str,
336    crate_name: &str,
337) -> Vec<ValidationIssue> {
338    let mut issues = Vec::new();
339    let mut keys_with_syntax_errors: HashSet<String> = HashSet::new();
340
341    // Convert parse errors to issues
342    for err in parse_errors {
343        issues.push(parser_error_to_issue(
344            err,
345            content,
346            locale,
347            file_name,
348            &mut keys_with_syntax_errors,
349        ));
350    }
351
352    // Scan Junk entries to find keys with errors
353    for entry in &resource.body {
354        if let ast::Entry::Junk { content: junk } = entry
355            && let Some(key) = extract_key_from_junk(junk)
356        {
357            keys_with_syntax_errors.insert(key);
358        }
359    }
360
361    // Build map of actual keys and their variables
362    let actual_keys: HashMap<String, HashSet<String>> = resource
363        .body
364        .iter()
365        .filter_map(|entry| match entry {
366            ast::Entry::Message(msg) => {
367                Some((msg.id.name.clone(), extract_variables_from_message(msg)))
368            },
369            _ => None,
370        })
371        .collect();
372
373    // Check for missing keys and variables
374    for (key, expected_vars) in expected_keys {
375        // Skip keys that have syntax errors - they're already reported
376        if keys_with_syntax_errors.contains(key) {
377            continue;
378        }
379
380        let Some(actual_vars) = actual_keys.get(key) else {
381            // Key is missing
382            issues.push(ValidationIssue::MissingKey(MissingKeyError {
383                src: NamedSource::new(file_name, content.to_string()),
384                key: key.clone(),
385                locale: locale.to_string(),
386                help: format!(
387                    "Add translation for '{}' in {}/{}.ftl",
388                    key, locale, crate_name
389                ),
390            }));
391            continue;
392        };
393
394        // Check for missing variables
395        for var in expected_vars {
396            if actual_vars.contains(var) {
397                continue;
398            }
399            let span = find_key_span(content, key)
400                .unwrap_or_else(|| SourceSpan::new(0_usize.into(), 1_usize));
401
402            issues.push(ValidationIssue::MissingVariable(MissingVariableWarning {
403                src: NamedSource::new(file_name, content.to_string()),
404                span,
405                variable: var.clone(),
406                key: key.clone(),
407                locale: locale.to_string(),
408                help: format!(
409                    "The Rust code generated by es-fluent declares variable '${}' but the translation omits it",
410                    var
411                ),
412            }));
413        }
414    }
415
416    issues
417}
418
419/// Convert a fluent-syntax ParserError to a ValidationIssue.
420fn parser_error_to_issue(
421    err: &ParserError,
422    content: &str,
423    locale: &str,
424    file_name: &str,
425    keys_with_syntax_errors: &mut HashSet<String>,
426) -> ValidationIssue {
427    // Try to extract message key from the junk slice if available
428    if let Some(ref slice) = err.slice {
429        let junk_content = &content[slice.clone()];
430        if let Some(key) = extract_key_from_junk(junk_content) {
431            keys_with_syntax_errors.insert(key);
432        }
433    }
434
435    // Calculate span from ParserError position
436    let span_len = if err.pos.end > err.pos.start {
437        err.pos.end - err.pos.start
438    } else {
439        1
440    };
441
442    ValidationIssue::SyntaxError(FtlSyntaxError {
443        src: NamedSource::new(file_name, content.to_string()),
444        span: SourceSpan::new(err.pos.start.into(), span_len),
445        locale: locale.to_string(),
446        file_name: file_name.to_string(),
447        help: err.kind.to_string(),
448    })
449}
450
451/// Try to extract a message key from junk content.
452/// Junk typically starts with the message identifier like "message-key = ..."
453fn extract_key_from_junk(junk: &str) -> Option<String> {
454    static KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+").unwrap());
455
456    KEY_REGEX
457        .find(junk.trim_start())
458        .map(|m| m.as_str().to_string())
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_find_key_span() {
467        let source = "## Comment\nhello = Hello\nworld = World";
468        let span = find_key_span(source, "hello").unwrap();
469        assert_eq!(span.offset(), 11);
470        assert_eq!(span.len(), 5);
471    }
472
473    #[test]
474    fn test_find_key_span_not_found() {
475        let source = "hello = Hello";
476        let span = find_key_span(source, "goodbye");
477        assert!(span.is_none());
478    }
479
480    #[test]
481    fn test_extract_key_from_junk() {
482        assert_eq!(
483            extract_key_from_junk("my-key = some value"),
484            Some("my-key".to_string())
485        );
486        assert_eq!(
487            extract_key_from_junk("  spaced-key = value"),
488            Some("spaced-key".to_string())
489        );
490        assert_eq!(extract_key_from_junk("# comment"), None);
491        assert_eq!(extract_key_from_junk(""), None);
492    }
493
494    #[test]
495    fn test_extract_variables() {
496        let content = "hello = Hello { $name }, you have { $count } messages";
497        let resource = parser::parse(content.to_string()).unwrap();
498
499        if let ast::Entry::Message(msg) = &resource.body[0] {
500            let vars = extract_variables_from_message(msg);
501            assert!(vars.contains("name"));
502            assert!(vars.contains("count"));
503            assert_eq!(vars.len(), 2);
504        } else {
505            panic!("Expected a message");
506        }
507    }
508}