Skip to main content

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,
14};
15use crate::ftl::extract_variables_from_message;
16use crate::generation::{prepare_monolithic_runner_crate, run_monolithic};
17use crate::utils::{
18    LoadedFtlFile, discover_and_load_ftl_files, ftl::main_ftl_path, get_all_locales, ui,
19};
20use anyhow::{Context as _, Result};
21use clap::Parser;
22use es_fluent_toml::I18nConfig;
23use fluent_syntax::ast;
24use indexmap::IndexMap;
25use miette::{NamedSource, SourceSpan};
26use serde::Deserialize;
27use std::collections::HashSet;
28use std::fs;
29use std::path::Path;
30use terminal_link::Link;
31
32/// Expected key information from inventory (deserialized from temp crate output).
33#[derive(Deserialize)]
34struct ExpectedKey {
35    key: String,
36    variables: Vec<String>,
37    /// The Rust source file where this key is defined.
38    source_file: Option<String>,
39    /// The line number in the Rust source file.
40    source_line: Option<u32>,
41}
42
43/// Runtime info about an expected key with its variables and source location.
44#[derive(Clone)]
45struct KeyInfo {
46    variables: HashSet<String>,
47    source_file: Option<String>,
48    source_line: Option<u32>,
49}
50
51/// The inventory data output from the temp crate.
52#[derive(Deserialize)]
53struct InventoryData {
54    expected_keys: Vec<ExpectedKey>,
55}
56
57/// Arguments for the check command.
58#[derive(Debug, Parser)]
59pub struct CheckArgs {
60    #[command(flatten)]
61    pub workspace: WorkspaceArgs,
62
63    /// Check all locales, not just the fallback language.
64    #[arg(long)]
65    pub all: bool,
66
67    /// Crates to skip during validation. Can be specified multiple times
68    /// (e.g., --ignore foo --ignore bar) or comma-separated (e.g., --ignore foo,bar).
69    #[arg(long, value_delimiter = ',')]
70    pub ignore: Vec<String>,
71
72    /// Force rebuild of the runner, ignoring the staleness cache.
73    #[arg(long)]
74    pub force_run: bool,
75}
76
77/// Context for FTL validation to reduce argument count.
78struct ValidationContext<'a> {
79    expected_keys: &'a IndexMap<String, KeyInfo>,
80    workspace_root: &'a Path,
81    manifest_dir: &'a Path,
82}
83
84/// Run the check command.
85pub fn run_check(args: CheckArgs) -> Result<(), CliError> {
86    let workspace = WorkspaceCrates::discover(args.workspace)?;
87
88    if !workspace.print_discovery(ui::print_check_header) {
89        ui::print_no_crates_found();
90        return Ok(());
91    }
92
93    // Convert ignore list to a HashSet for efficient lookups
94    let ignore_crates: HashSet<String> = args.ignore.into_iter().collect();
95    let force_run = args.force_run;
96
97    // Filter out ignored crates
98    let crates_to_check: Vec<_> = workspace
99        .valid
100        .iter()
101        .filter(|k| !ignore_crates.contains(&k.name))
102        .collect();
103
104    // Validate that all ignored crates are known
105    if !ignore_crates.is_empty() {
106        let all_crate_names: HashSet<String> =
107            workspace.valid.iter().map(|k| k.name.clone()).collect();
108
109        let mut unknown_crates: Vec<&String> = ignore_crates
110            .iter()
111            .filter(|c| !all_crate_names.contains(*c))
112            .collect();
113
114        if !unknown_crates.is_empty() {
115            // Sort for deterministic error messages
116            unknown_crates.sort();
117
118            return Err(CliError::Other(format!(
119                "Unknown crates passed to --ignore: {}",
120                unknown_crates
121                    .iter()
122                    .map(|c| format!("'{}'", c))
123                    .collect::<Vec<_>>()
124                    .join(", ")
125            )));
126        }
127    }
128
129    if crates_to_check.is_empty() {
130        ui::print_no_crates_found();
131        return Ok(());
132    }
133
134    // Prepare monolithic temp crate once for all checks
135    prepare_monolithic_runner_crate(&workspace.workspace_info)
136        .map_err(|e| CliError::Other(e.to_string()))?;
137
138    // First pass: collect all expected keys from crates
139    let temp_dir =
140        es_fluent_derive_core::get_es_fluent_temp_dir(&workspace.workspace_info.root_dir);
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 = es_fluent_derive_core::get_metadata_inventory_path(temp_dir, crate_name);
237    let json_str = fs::read_to_string(&inventory_path)
238        .with_context(|| format!("Failed to read {}", inventory_path.display()))?;
239
240    let data: InventoryData =
241        serde_json::from_str(&json_str).context("Failed to parse inventory JSON")?;
242
243    // Convert to IndexMap with KeyInfo for richer metadata
244    let mut expected_keys = IndexMap::new();
245    for key_info in data.expected_keys {
246        expected_keys.insert(
247            key_info.key,
248            KeyInfo {
249                variables: key_info.variables.into_iter().collect(),
250                source_file: key_info.source_file,
251                source_line: key_info.source_line,
252            },
253        );
254    }
255
256    Ok(expected_keys)
257}
258
259/// Validate FTL files against expected keys using shared discovery logic.
260fn validate_ftl_files(
261    krate: &CrateInfo,
262    workspace_root: &Path,
263    expected_keys: &IndexMap<String, KeyInfo>,
264    check_all: bool,
265) -> Result<Vec<ValidationIssue>> {
266    let config = I18nConfig::read_from_path(&krate.i18n_config_path)
267        .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
268
269    let assets_dir = krate.manifest_dir.join(&config.assets_dir);
270
271    let locales: Vec<String> = if check_all {
272        get_all_locales(&assets_dir)?
273    } else {
274        vec![config.fallback_language.clone()]
275    };
276
277    let mut issues = Vec::new();
278
279    for locale in &locales {
280        // Use shared discovery and loading logic
281        match discover_and_load_ftl_files(&assets_dir, locale, &krate.name) {
282            Ok(loaded_files) => {
283                if loaded_files.is_empty() {
284                    // No FTL files found at all - treat as missing main file
285                    let ftl_abs_path = main_ftl_path(&assets_dir, locale, &krate.name);
286                    let ftl_relative_path = to_relative_path(&ftl_abs_path, workspace_root);
287                    let ftl_header_link = Link::new(
288                        &ftl_relative_path,
289                        &format!("file://{}", ftl_abs_path.display()),
290                    )
291                    .to_string();
292
293                    issues.extend(missing_file_issues(
294                        expected_keys,
295                        locale,
296                        &krate.name,
297                        &ftl_header_link,
298                    ));
299                    continue;
300                }
301
302                // Validate all loaded files together
303                let ctx = ValidationContext {
304                    expected_keys,
305                    workspace_root,
306                    manifest_dir: &krate.manifest_dir,
307                };
308
309                issues.extend(validate_loaded_ftl_files(loaded_files, locale, &ctx));
310            },
311            Err(e) => {
312                // Handle discovery/loading errors
313                let ftl_abs_path = main_ftl_path(&assets_dir, locale, &krate.name);
314                let ftl_relative_path = to_relative_path(&ftl_abs_path, workspace_root);
315                let ftl_header_link = Link::new(
316                    &ftl_relative_path,
317                    &format!("file://{}", ftl_abs_path.display()),
318                )
319                .to_string();
320
321                issues.push(ValidationIssue::SyntaxError(FtlSyntaxError {
322                    src: NamedSource::new(ftl_header_link, String::new()),
323                    span: SourceSpan::new(0_usize.into(), 1_usize),
324                    locale: locale.clone(),
325                    help: format!("Failed to discover FTL files: {}", e),
326                }));
327            },
328        }
329    }
330
331    Ok(issues)
332}
333
334/// Generate missing key issues when an FTL file doesn't exist.
335fn missing_file_issues(
336    expected_keys: &IndexMap<String, KeyInfo>,
337    locale: &str,
338    _crate_name: &str,
339    ftl_path: &str,
340) -> Vec<ValidationIssue> {
341    expected_keys
342        .keys()
343        .map(|key| {
344            ValidationIssue::MissingKey(MissingKeyError {
345                src: NamedSource::new(ftl_path, String::new()),
346                key: key.clone(),
347                locale: locale.to_string(),
348                help: format!("Add translation for '{}' in {}", key, ftl_path),
349            })
350        })
351        .collect()
352}
353
354/// Validate multiple loaded FTL files against expected keys.
355fn validate_loaded_ftl_files(
356    loaded_files: Vec<LoadedFtlFile>,
357    locale: &str,
358    ctx: &ValidationContext,
359) -> Vec<ValidationIssue> {
360    let mut issues = Vec::new();
361    let mut all_actual_keys: IndexMap<String, (HashSet<String>, String, String)> = IndexMap::new(); // key -> (vars, file_path, header_link)
362
363    // Process all files and collect keys
364    for file in loaded_files {
365        let _content = fs::read_to_string(&file.abs_path).unwrap_or_default();
366        let ftl_relative_path = to_relative_path(&file.abs_path, ctx.workspace_root);
367        let ftl_header_link = Link::new(
368            &ftl_relative_path,
369            &format!("file://{}", file.abs_path.display()),
370        )
371        .to_string();
372
373        // Collect actual keys from this file
374        for entry in &file.resource.body {
375            if let ast::Entry::Message(msg) = entry {
376                let key = msg.id.name.clone();
377                let vars = extract_variables_from_message(msg);
378
379                // Store the key with its file info
380                all_actual_keys.insert(
381                    key.clone(),
382                    (vars, ftl_relative_path.clone(), ftl_header_link.clone()),
383                );
384            }
385        }
386    }
387
388    // Check for missing keys and variables
389    for (key, key_info) in ctx.expected_keys {
390        let Some((actual_vars, _file_path, header_link)) = all_actual_keys.get(key) else {
391            // Key is missing from all files - report it in the first file as a reasonable default
392            let default_file_path = if let Some((_, path, link)) = all_actual_keys.values().next() {
393                (path.clone(), link.clone())
394            } else {
395                // No files at all, this case should be handled earlier but let's provide a fallback
396                (format!("{}.ftl", "unknown"), format!("{}.ftl", "unknown"))
397            };
398
399            issues.push(ValidationIssue::MissingKey(MissingKeyError {
400                src: NamedSource::new(default_file_path.1, String::new()),
401                key: key.clone(),
402                locale: locale.to_string(),
403                help: format!("Add translation for '{}' in {}", key, default_file_path.0),
404            }));
405            continue;
406        };
407
408        // Check for missing variables
409        for var in &key_info.variables {
410            if actual_vars.contains(var) {
411                continue;
412            }
413
414            // Find the span in the actual file (this is approximate)
415            let span = SourceSpan::new(0_usize.into(), 1_usize);
416
417            // Build help message with source location if available
418            let help = match (&key_info.source_file, key_info.source_line) {
419                (Some(file), Some(line)) => {
420                    let file_path = Path::new(file);
421                    let abs_file = if file_path.is_absolute() {
422                        file_path.to_path_buf()
423                    } else {
424                        ctx.manifest_dir.join(file_path)
425                    };
426
427                    let rel_file = to_relative_path(&abs_file, ctx.workspace_root);
428                    let file_label = format!("{rel_file}:{line}");
429                    let file_url = format!("file://{}", abs_file.display());
430                    let file_link = Link::new(&file_label, &file_url);
431
432                    format!("Variable '${var}' is declared at {file_link}")
433                },
434                (Some(file), None) => {
435                    let file_path = Path::new(file);
436                    let abs_file = if file_path.is_absolute() {
437                        file_path.to_path_buf()
438                    } else {
439                        ctx.manifest_dir.join(file_path)
440                    };
441                    let rel_file = to_relative_path(&abs_file, ctx.workspace_root);
442
443                    let file_url = format!("file://{}", abs_file.display());
444                    let file_link = Link::new(&rel_file, &file_url);
445
446                    format!("Variable '${var}' is declared in {file_link}")
447                },
448                _ => format!("Variable '${var}' is declared in Rust code"),
449            };
450
451            issues.push(ValidationIssue::MissingVariable(MissingVariableWarning {
452                src: NamedSource::new(header_link.clone(), String::new()),
453                span,
454                variable: var.clone(),
455                key: key.clone(),
456                locale: locale.to_string(),
457                help,
458            }));
459        }
460    }
461
462    issues
463}
464
465/// Helper to make a path relative to a base path (e.g. workspace root).
466fn to_relative_path(path: &Path, base: &Path) -> String {
467    // Try to canonicalize both for accurate diffing
468    let path_canon = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
469    let base_canon = fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
470
471    // Try to strip prefix
472    if let Ok(rel) = path_canon.strip_prefix(&base_canon) {
473        return rel.display().to_string();
474    }
475
476    // If straightforward strip failed, we can return the path as is or try simple path strip
477    // (sometimes canonicalize fails or resolves symlinks unpredictably)
478    if let Ok(rel) = path.strip_prefix(base) {
479        return rel.display().to_string();
480    }
481
482    // Fallback: return absolute path or best effort
483    path.display().to_string()
484}