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 indexmap::IndexMap;
24use miette::{NamedSource, SourceSpan};
25use regex::Regex;
26use serde::Deserialize;
27use std::collections::HashSet;
28use std::fs;
29use std::path::Path;
30use std::sync::LazyLock;
31use terminal_link::Link;
32
33/// Expected key information from inventory (deserialized from temp crate output).
34#[derive(Deserialize)]
35struct ExpectedKey {
36    key: String,
37    variables: Vec<String>,
38    /// The Rust source file where this key is defined.
39    source_file: Option<String>,
40    /// The line number in the Rust source file.
41    source_line: Option<u32>,
42}
43
44/// Runtime info about an expected key with its variables and source location.
45#[derive(Clone)]
46struct KeyInfo {
47    variables: HashSet<String>,
48    source_file: Option<String>,
49    source_line: Option<u32>,
50}
51
52/// The inventory data output from the temp crate.
53#[derive(Deserialize)]
54struct InventoryData {
55    expected_keys: Vec<ExpectedKey>,
56}
57
58/// Arguments for the check command.
59#[derive(Debug, Parser)]
60pub struct CheckArgs {
61    #[command(flatten)]
62    pub workspace: WorkspaceArgs,
63
64    /// Check all locales, not just the fallback language.
65    #[arg(long)]
66    pub all: bool,
67
68    /// Crates to skip during validation. Can be specified multiple times
69    /// (e.g., --ignore foo --ignore bar) or comma-separated (e.g., --ignore foo,bar).
70    #[arg(long, value_delimiter = ',')]
71    pub ignore: Vec<String>,
72
73    /// Force rebuild of the runner, ignoring the staleness cache.
74    #[arg(long)]
75    pub force_run: bool,
76}
77
78/// Context for FTL validation to reduce argument count.
79struct ValidationContext<'a> {
80    expected_keys: &'a IndexMap<String, KeyInfo>,
81    workspace_root: &'a Path,
82    manifest_dir: &'a Path,
83}
84
85/// Run the check command.
86pub fn run_check(args: CheckArgs) -> Result<(), CliError> {
87    let workspace = WorkspaceCrates::discover(args.workspace)?;
88
89    if !workspace.print_discovery(ui::print_check_header) {
90        ui::print_no_crates_found();
91        return Ok(());
92    }
93
94    // Convert ignore list to a HashSet for efficient lookups
95    let ignore_crates: HashSet<String> = args.ignore.into_iter().collect();
96    let force_run = args.force_run;
97
98    // Filter out ignored crates
99    let crates_to_check: Vec<_> = workspace
100        .valid
101        .iter()
102        .filter(|k| !ignore_crates.contains(&k.name))
103        .collect();
104
105    // Validate that all ignored crates are known
106    if !ignore_crates.is_empty() {
107        let all_crate_names: HashSet<String> =
108            workspace.valid.iter().map(|k| k.name.clone()).collect();
109
110        let mut unknown_crates: Vec<&String> = ignore_crates
111            .iter()
112            .filter(|c| !all_crate_names.contains(*c))
113            .collect();
114
115        if !unknown_crates.is_empty() {
116            // Sort for deterministic error messages
117            unknown_crates.sort();
118
119            return Err(CliError::Other(format!(
120                "Unknown crates passed to --ignore: {}",
121                unknown_crates
122                    .iter()
123                    .map(|c| format!("'{}'", c))
124                    .collect::<Vec<_>>()
125                    .join(", ")
126            )));
127        }
128    }
129
130    if crates_to_check.is_empty() {
131        ui::print_no_crates_found();
132        return Ok(());
133    }
134
135    // Prepare monolithic temp crate once for all checks
136    prepare_monolithic_runner_crate(&workspace.workspace_info)
137        .map_err(|e| CliError::Other(e.to_string()))?;
138
139    // First pass: collect all expected keys from crates
140    let temp_dir = workspace.workspace_info.root_dir.join(".es-fluent");
141
142    let pb = ui::create_progress_bar(crates_to_check.len() as u64, "Collecting keys...");
143
144    for krate in &crates_to_check {
145        pb.set_message(format!("Scanning {}", krate.name));
146        run_monolithic(
147            &workspace.workspace_info,
148            "check",
149            &krate.name,
150            &[],
151            force_run,
152        )
153        .map_err(|e| CliError::Other(e.to_string()))?;
154        pb.inc(1);
155    }
156
157    pb.finish_and_clear();
158
159    // Second pass: validate FTL files
160    let mut all_issues: Vec<ValidationIssue> = Vec::new();
161
162    let pb = ui::create_progress_bar(crates_to_check.len() as u64, "Checking crates...");
163
164    for krate in &crates_to_check {
165        pb.set_message(format!("Checking {}", krate.name));
166
167        match validate_crate(
168            krate,
169            &workspace.workspace_info.root_dir,
170            &temp_dir,
171            args.all,
172        ) {
173            Ok(issues) => {
174                all_issues.extend(issues);
175            },
176            Err(e) => {
177                // If error, print above progress bar
178                pb.suspend(|| {
179                    ui::print_check_error(&krate.name, &e.to_string());
180                });
181            },
182        }
183        pb.inc(1);
184    }
185
186    pb.finish_and_clear();
187
188    // Sort issues for deterministic output
189    all_issues.sort_by_cached_key(|issue| issue.sort_key());
190
191    let error_count = all_issues
192        .iter()
193        .filter(|i| {
194            matches!(
195                i,
196                ValidationIssue::MissingKey(_) | ValidationIssue::SyntaxError(_)
197            )
198        })
199        .count();
200    let warning_count = all_issues
201        .iter()
202        .filter(|i| matches!(i, ValidationIssue::MissingVariable(_)))
203        .count();
204
205    if all_issues.is_empty() {
206        ui::print_check_success();
207        Ok(())
208    } else {
209        Err(CliError::Validation(ValidationReport {
210            error_count,
211            warning_count,
212            issues: all_issues,
213        }))
214    }
215}
216
217/// Validate a single crate's FTL files using already-collected inventory data.
218fn validate_crate(
219    krate: &CrateInfo,
220    workspace_root: &Path,
221    temp_dir: &Path,
222    check_all: bool,
223) -> Result<Vec<ValidationIssue>> {
224    // Read the inventory that was already collected in the first pass
225    let expected_keys = read_inventory_file(temp_dir, &krate.name)?;
226
227    // Validate FTL files against expected keys
228    validate_ftl_files(krate, workspace_root, &expected_keys, check_all)
229}
230
231/// Read inventory data from the generated inventory.json file.
232fn read_inventory_file(
233    temp_dir: &std::path::Path,
234    crate_name: &str,
235) -> Result<IndexMap<String, KeyInfo>> {
236    let inventory_path = temp_dir
237        .join("metadata")
238        .join(crate_name)
239        .join("inventory.json");
240    let json_str = fs::read_to_string(&inventory_path)
241        .with_context(|| format!("Failed to read {}", inventory_path.display()))?;
242
243    let data: InventoryData =
244        serde_json::from_str(&json_str).context("Failed to parse inventory JSON")?;
245
246    // Convert to IndexMap with KeyInfo for richer metadata
247    let mut expected_keys = IndexMap::new();
248    for key_info in data.expected_keys {
249        expected_keys.insert(
250            key_info.key,
251            KeyInfo {
252                variables: key_info.variables.into_iter().collect(),
253                source_file: key_info.source_file,
254                source_line: key_info.source_line,
255            },
256        );
257    }
258
259    Ok(expected_keys)
260}
261
262/// Result of loading an FTL file for a locale.
263enum LocaleLoadResult {
264    /// File doesn't exist.
265    NotFound,
266    /// Failed to read the file.
267    ReadError(String),
268    /// Successfully loaded.
269    Loaded {
270        content: String,
271        resource: ast::Resource<String>,
272        parse_errors: Vec<ParserError>,
273    },
274}
275
276/// Load an FTL file for a locale.
277fn load_locale_ftl(assets_dir: &Path, locale: &str, crate_name: &str) -> LocaleLoadResult {
278    let ftl_file = assets_dir.join(locale).join(format!("{}.ftl", crate_name));
279
280    if !ftl_file.exists() {
281        return LocaleLoadResult::NotFound;
282    }
283
284    let content = match fs::read_to_string(&ftl_file) {
285        Ok(c) => c,
286        Err(e) => return LocaleLoadResult::ReadError(e.to_string()),
287    };
288
289    let (resource, parse_errors) = match parser::parse(content.clone()) {
290        Ok(res) => (res, vec![]),
291        Err((res, errors)) => (res, errors),
292    };
293
294    LocaleLoadResult::Loaded {
295        content,
296        resource,
297        parse_errors,
298    }
299}
300
301/// Validate FTL files against expected keys using fluent-syntax directly.
302fn validate_ftl_files(
303    krate: &CrateInfo,
304    workspace_root: &Path,
305    expected_keys: &IndexMap<String, KeyInfo>,
306    check_all: bool,
307) -> Result<Vec<ValidationIssue>> {
308    let config = I18nConfig::read_from_path(&krate.i18n_config_path)
309        .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
310
311    let assets_dir = krate.manifest_dir.join(&config.assets_dir);
312
313    let locales: Vec<String> = if check_all {
314        get_all_locales(&assets_dir)?
315    } else {
316        vec![config.fallback_language.clone()]
317    };
318
319    let mut issues = Vec::new();
320
321    for locale in &locales {
322        let ftl_abs_path = assets_dir.join(locale).join(format!("{}.ftl", krate.name));
323        let ftl_relative_path = to_relative_path(&ftl_abs_path, workspace_root);
324
325        let ftl_url = format!("file://{}", ftl_abs_path.display());
326        let ftl_header_link = Link::new(&ftl_relative_path, &ftl_url).to_string();
327
328        match load_locale_ftl(&assets_dir, locale, &krate.name) {
329            LocaleLoadResult::NotFound => {
330                issues.extend(missing_file_issues(
331                    expected_keys,
332                    locale,
333                    &krate.name,
334                    &ftl_header_link,
335                ));
336                continue;
337            },
338            LocaleLoadResult::ReadError(err) => {
339                issues.push(ValidationIssue::SyntaxError(FtlSyntaxError {
340                    src: NamedSource::new(ftl_header_link, String::new()),
341                    span: SourceSpan::new(0_usize.into(), 1_usize),
342                    locale: locale.clone(),
343                    help: format!("Failed to read file: {}", err),
344                }));
345                continue;
346            },
347            LocaleLoadResult::Loaded {
348                content,
349                resource,
350                parse_errors,
351            } => {
352                let ctx = ValidationContext {
353                    expected_keys,
354                    workspace_root,
355                    manifest_dir: &krate.manifest_dir,
356                };
357
358                issues.extend(validate_loaded_ftl(
359                    &content,
360                    &resource,
361                    &parse_errors,
362                    locale,
363                    &ftl_relative_path,
364                    &ctx,
365                ));
366            },
367        }
368    }
369
370    Ok(issues)
371}
372
373/// Generate missing key issues when an FTL file doesn't exist.
374fn missing_file_issues(
375    expected_keys: &IndexMap<String, KeyInfo>,
376    locale: &str,
377    _crate_name: &str,
378    ftl_path: &str,
379) -> Vec<ValidationIssue> {
380    expected_keys
381        .keys()
382        .map(|key| {
383            ValidationIssue::MissingKey(MissingKeyError {
384                src: NamedSource::new(ftl_path, String::new()),
385                key: key.clone(),
386                locale: locale.to_string(),
387                help: format!("Add translation for '{}' in {}", key, ftl_path),
388            })
389        })
390        .collect()
391}
392
393/// Validate a loaded FTL file against expected keys.
394/// Validate a loaded FTL file against expected keys.
395fn validate_loaded_ftl(
396    content: &str,
397    resource: &ast::Resource<String>,
398    parse_errors: &[ParserError],
399    locale: &str,
400    file_name: &str,
401    ctx: &ValidationContext,
402) -> Vec<ValidationIssue> {
403    let mut issues = Vec::new();
404    let mut keys_with_syntax_errors: HashSet<String> = HashSet::new();
405
406    // Calculate header link (with absolute path target but relative path text)
407    let ftl_abs_path = ctx.workspace_root.join(file_name);
408    let ftl_header_url = format!("file://{}", ftl_abs_path.display());
409    // file_name here is expected to be relative path (passed from caller)
410    let ftl_header_link = Link::new(file_name, &ftl_header_url).to_string();
411
412    // Convert parse errors to issues
413    for err in parse_errors {
414        issues.push(parser_error_to_issue(
415            err,
416            content,
417            locale,
418            &ftl_header_link,
419            &mut keys_with_syntax_errors,
420        ));
421    }
422
423    // Scan Junk entries to find keys with errors
424    for entry in &resource.body {
425        if let ast::Entry::Junk { content: junk } = entry
426            && let Some(key) = extract_key_from_junk(junk)
427        {
428            keys_with_syntax_errors.insert(key);
429        }
430    }
431
432    // Build map of actual keys and their variables
433    let actual_keys: IndexMap<String, HashSet<String>> = resource
434        .body
435        .iter()
436        .filter_map(|entry| match entry {
437            ast::Entry::Message(msg) => {
438                Some((msg.id.name.clone(), extract_variables_from_message(msg)))
439            },
440            _ => None,
441        })
442        .collect();
443
444    // Check for missing keys and variables
445    for (key, key_info) in ctx.expected_keys {
446        // Skip keys that have syntax errors - they're already reported
447        if keys_with_syntax_errors.contains(key) {
448            continue;
449        }
450
451        let Some(actual_vars) = actual_keys.get(key) else {
452            // Key is missing
453            issues.push(ValidationIssue::MissingKey(MissingKeyError {
454                src: NamedSource::new(ftl_header_link.clone(), content.to_string()),
455                key: key.clone(),
456                locale: locale.to_string(),
457                help: format!("Add translation for '{}' in {}", key, ftl_header_link),
458            }));
459            continue;
460        };
461
462        // Check for missing variables
463        for var in &key_info.variables {
464            if actual_vars.contains(var) {
465                continue;
466            }
467            let span = find_key_span(content, key)
468                .unwrap_or_else(|| SourceSpan::new(0_usize.into(), 1_usize));
469
470            // Build help message with source location if available
471            let help = match (&key_info.source_file, key_info.source_line) {
472                (Some(file), Some(line)) => {
473                    let file_path = Path::new(file);
474                    let abs_file = if file_path.is_absolute() {
475                        file_path.to_path_buf()
476                    } else {
477                        ctx.manifest_dir.join(file_path)
478                    };
479
480                    // We still want relative path for display text (relative to workspace if possible usually, or crate relative)
481                    // existing logic used to_relative_path(Path::new(file), workspace_root)
482                    // If file is "src/lib.rs", it's relative to crate. But we want relative to workspace?
483                    // to_relative_path expects absolute or correct relative base.
484                    // Let's use abs_file for to_relative_path to be safe.
485                    let rel_file = to_relative_path(&abs_file, ctx.workspace_root);
486
487                    let file_label = format!("{rel_file}:{line}");
488                    let file_url = format!("file://{}", abs_file.display());
489                    let file_link = Link::new(&file_label, &file_url);
490
491                    format!("Variable '${var}' is declared at {file_link}")
492                },
493                (Some(file), None) => {
494                    let file_path = Path::new(file);
495                    let abs_file = if file_path.is_absolute() {
496                        file_path.to_path_buf()
497                    } else {
498                        ctx.manifest_dir.join(file_path)
499                    };
500                    let rel_file = to_relative_path(&abs_file, ctx.workspace_root);
501
502                    let file_url = format!("file://{}", abs_file.display());
503                    let file_link = Link::new(&rel_file, &file_url);
504
505                    format!("Variable '${var}' is declared in {file_link}")
506                },
507                _ => format!("Variable '${var}' is declared in Rust code"),
508            };
509
510            issues.push(ValidationIssue::MissingVariable(MissingVariableWarning {
511                src: NamedSource::new(ftl_header_link.clone(), content.to_string()),
512                span,
513                variable: var.clone(),
514                key: key.clone(),
515                locale: locale.to_string(),
516                help,
517            }));
518        }
519    }
520
521    issues
522}
523
524/// Convert a fluent-syntax ParserError to a ValidationIssue.
525fn parser_error_to_issue(
526    err: &ParserError,
527    content: &str,
528    locale: &str,
529    display_name: &str,
530    keys_with_syntax_errors: &mut HashSet<String>,
531) -> ValidationIssue {
532    // Try to extract message key from the junk slice if available
533    if let Some(ref slice) = err.slice {
534        let junk_content = &content[slice.clone()];
535        if let Some(key) = extract_key_from_junk(junk_content) {
536            keys_with_syntax_errors.insert(key);
537        }
538    }
539
540    // Calculate span from ParserError position
541    let span_len = if err.pos.end > err.pos.start {
542        err.pos.end - err.pos.start
543    } else {
544        1
545    };
546
547    ValidationIssue::SyntaxError(FtlSyntaxError {
548        src: NamedSource::new(display_name, content.to_string()),
549        span: SourceSpan::new(err.pos.start.into(), span_len),
550        locale: locale.to_string(),
551        help: err.kind.to_string(),
552    })
553}
554
555/// Try to extract a message key from junk content.
556/// Junk typically starts with the message identifier like "message-key = ..."
557fn extract_key_from_junk(junk: &str) -> Option<String> {
558    static KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+").unwrap());
559
560    KEY_REGEX
561        .find(junk.trim_start())
562        .map(|m| m.as_str().to_string())
563}
564
565/// Helper to make a path relative to a base path (e.g. workspace root).
566fn to_relative_path(path: &Path, base: &Path) -> String {
567    // Try to canonicalize both for accurate diffing
568    let path_canon = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
569    let base_canon = fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
570
571    // Try to strip prefix
572    if let Ok(rel) = path_canon.strip_prefix(&base_canon) {
573        return rel.display().to_string();
574    }
575
576    // If straightforward strip failed, we can return the path as is or try simple path strip
577    // (sometimes canonicalize fails or resolves symlinks unpredictably)
578    if let Ok(rel) = path.strip_prefix(base) {
579        return rel.display().to_string();
580    }
581
582    // Fallback: return absolute path or best effort
583    path.display().to_string()
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn test_find_key_span() {
592        let source = "## Comment\nhello = Hello\nworld = World";
593        let span = find_key_span(source, "hello").unwrap();
594        assert_eq!(span.offset(), 11);
595        assert_eq!(span.len(), 5);
596    }
597
598    #[test]
599    fn test_find_key_span_not_found() {
600        let source = "hello = Hello";
601        let span = find_key_span(source, "goodbye");
602        assert!(span.is_none());
603    }
604
605    #[test]
606    fn test_extract_key_from_junk() {
607        assert_eq!(
608            extract_key_from_junk("my-key = some value"),
609            Some("my-key".to_string())
610        );
611        assert_eq!(
612            extract_key_from_junk("  spaced-key = value"),
613            Some("spaced-key".to_string())
614        );
615        assert_eq!(extract_key_from_junk("# comment"), None);
616        assert_eq!(extract_key_from_junk(""), None);
617    }
618
619    #[test]
620    fn test_extract_variables() {
621        let content = "hello = Hello { $name }, you have { $count } messages";
622        let resource = parser::parse(content.to_string()).unwrap();
623
624        if let ast::Entry::Message(msg) = &resource.body[0] {
625            let vars = extract_variables_from_message(msg);
626            assert!(vars.contains("name"));
627            assert!(vars.contains("count"));
628            assert_eq!(vars.len(), 2);
629        } else {
630            panic!("Expected a message");
631        }
632    }
633}