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 = workspace.workspace_info.root_dir.join(".es-fluent");
140
141    let pb = ui::create_progress_bar(crates_to_check.len() as u64, "Collecting keys...");
142
143    for krate in &crates_to_check {
144        pb.set_message(format!("Scanning {}", krate.name));
145        run_monolithic(
146            &workspace.workspace_info,
147            "check",
148            &krate.name,
149            &[],
150            force_run,
151        )
152        .map_err(|e| CliError::Other(e.to_string()))?;
153        pb.inc(1);
154    }
155
156    pb.finish_and_clear();
157
158    // Second pass: validate FTL files
159    let mut all_issues: Vec<ValidationIssue> = Vec::new();
160
161    let pb = ui::create_progress_bar(crates_to_check.len() as u64, "Checking crates...");
162
163    for krate in &crates_to_check {
164        pb.set_message(format!("Checking {}", krate.name));
165
166        match validate_crate(
167            krate,
168            &workspace.workspace_info.root_dir,
169            &temp_dir,
170            args.all,
171        ) {
172            Ok(issues) => {
173                all_issues.extend(issues);
174            },
175            Err(e) => {
176                // If error, print above progress bar
177                pb.suspend(|| {
178                    ui::print_check_error(&krate.name, &e.to_string());
179                });
180            },
181        }
182        pb.inc(1);
183    }
184
185    pb.finish_and_clear();
186
187    // Sort issues for deterministic output
188    all_issues.sort_by_cached_key(|issue| issue.sort_key());
189
190    let error_count = all_issues
191        .iter()
192        .filter(|i| {
193            matches!(
194                i,
195                ValidationIssue::MissingKey(_) | ValidationIssue::SyntaxError(_)
196            )
197        })
198        .count();
199    let warning_count = all_issues
200        .iter()
201        .filter(|i| matches!(i, ValidationIssue::MissingVariable(_)))
202        .count();
203
204    if all_issues.is_empty() {
205        ui::print_check_success();
206        Ok(())
207    } else {
208        Err(CliError::Validation(ValidationReport {
209            error_count,
210            warning_count,
211            issues: all_issues,
212        }))
213    }
214}
215
216/// Validate a single crate's FTL files using already-collected inventory data.
217fn validate_crate(
218    krate: &CrateInfo,
219    workspace_root: &Path,
220    temp_dir: &Path,
221    check_all: bool,
222) -> Result<Vec<ValidationIssue>> {
223    // Read the inventory that was already collected in the first pass
224    let expected_keys = read_inventory_file(temp_dir, &krate.name)?;
225
226    // Validate FTL files against expected keys
227    validate_ftl_files(krate, workspace_root, &expected_keys, check_all)
228}
229
230/// Read inventory data from the generated inventory.json file.
231fn read_inventory_file(
232    temp_dir: &std::path::Path,
233    crate_name: &str,
234) -> Result<IndexMap<String, KeyInfo>> {
235    let inventory_path = temp_dir
236        .join("metadata")
237        .join(crate_name)
238        .join("inventory.json");
239    let json_str = fs::read_to_string(&inventory_path)
240        .with_context(|| format!("Failed to read {}", inventory_path.display()))?;
241
242    let data: InventoryData =
243        serde_json::from_str(&json_str).context("Failed to parse inventory JSON")?;
244
245    // Convert to IndexMap with KeyInfo for richer metadata
246    let mut expected_keys = IndexMap::new();
247    for key_info in data.expected_keys {
248        expected_keys.insert(
249            key_info.key,
250            KeyInfo {
251                variables: key_info.variables.into_iter().collect(),
252                source_file: key_info.source_file,
253                source_line: key_info.source_line,
254            },
255        );
256    }
257
258    Ok(expected_keys)
259}
260
261/// Validate FTL files against expected keys using shared discovery logic.
262fn validate_ftl_files(
263    krate: &CrateInfo,
264    workspace_root: &Path,
265    expected_keys: &IndexMap<String, KeyInfo>,
266    check_all: bool,
267) -> Result<Vec<ValidationIssue>> {
268    let config = I18nConfig::read_from_path(&krate.i18n_config_path)
269        .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
270
271    let assets_dir = krate.manifest_dir.join(&config.assets_dir);
272
273    let locales: Vec<String> = if check_all {
274        get_all_locales(&assets_dir)?
275    } else {
276        vec![config.fallback_language.clone()]
277    };
278
279    let mut issues = Vec::new();
280
281    for locale in &locales {
282        // Use shared discovery and loading logic
283        match discover_and_load_ftl_files(&assets_dir, locale, &krate.name) {
284            Ok(loaded_files) => {
285                if loaded_files.is_empty() {
286                    // No FTL files found at all - treat as missing main file
287                    let ftl_abs_path = main_ftl_path(&assets_dir, locale, &krate.name);
288                    let ftl_relative_path = to_relative_path(&ftl_abs_path, workspace_root);
289                    let ftl_header_link = Link::new(
290                        &ftl_relative_path,
291                        &format!("file://{}", ftl_abs_path.display()),
292                    )
293                    .to_string();
294
295                    issues.extend(missing_file_issues(
296                        expected_keys,
297                        locale,
298                        &krate.name,
299                        &ftl_header_link,
300                    ));
301                    continue;
302                }
303
304                // Validate all loaded files together
305                let ctx = ValidationContext {
306                    expected_keys,
307                    workspace_root,
308                    manifest_dir: &krate.manifest_dir,
309                };
310
311                issues.extend(validate_loaded_ftl_files(loaded_files, locale, &ctx));
312            },
313            Err(e) => {
314                // Handle discovery/loading errors
315                let ftl_abs_path = main_ftl_path(&assets_dir, locale, &krate.name);
316                let ftl_relative_path = to_relative_path(&ftl_abs_path, workspace_root);
317                let ftl_header_link = Link::new(
318                    &ftl_relative_path,
319                    &format!("file://{}", ftl_abs_path.display()),
320                )
321                .to_string();
322
323                issues.push(ValidationIssue::SyntaxError(FtlSyntaxError {
324                    src: NamedSource::new(ftl_header_link, String::new()),
325                    span: SourceSpan::new(0_usize.into(), 1_usize),
326                    locale: locale.clone(),
327                    help: format!("Failed to discover FTL files: {}", e),
328                }));
329            },
330        }
331    }
332
333    Ok(issues)
334}
335
336/// Generate missing key issues when an FTL file doesn't exist.
337fn missing_file_issues(
338    expected_keys: &IndexMap<String, KeyInfo>,
339    locale: &str,
340    _crate_name: &str,
341    ftl_path: &str,
342) -> Vec<ValidationIssue> {
343    expected_keys
344        .keys()
345        .map(|key| {
346            ValidationIssue::MissingKey(MissingKeyError {
347                src: NamedSource::new(ftl_path, String::new()),
348                key: key.clone(),
349                locale: locale.to_string(),
350                help: format!("Add translation for '{}' in {}", key, ftl_path),
351            })
352        })
353        .collect()
354}
355
356/// Validate multiple loaded FTL files against expected keys.
357fn validate_loaded_ftl_files(
358    loaded_files: Vec<LoadedFtlFile>,
359    locale: &str,
360    ctx: &ValidationContext,
361) -> Vec<ValidationIssue> {
362    let mut issues = Vec::new();
363    let mut all_actual_keys: IndexMap<String, (HashSet<String>, String, String)> = IndexMap::new(); // key -> (vars, file_path, header_link)
364
365    // Process all files and collect keys
366    for file in loaded_files {
367        let _content = fs::read_to_string(&file.abs_path).unwrap_or_default();
368        let ftl_relative_path = to_relative_path(&file.abs_path, ctx.workspace_root);
369        let ftl_header_link = Link::new(
370            &ftl_relative_path,
371            &format!("file://{}", file.abs_path.display()),
372        )
373        .to_string();
374
375        // Collect actual keys from this file
376        for entry in &file.resource.body {
377            if let ast::Entry::Message(msg) = entry {
378                let key = msg.id.name.clone();
379                let vars = extract_variables_from_message(msg);
380
381                // Store the key with its file info
382                all_actual_keys.insert(
383                    key.clone(),
384                    (vars, ftl_relative_path.clone(), ftl_header_link.clone()),
385                );
386            }
387        }
388    }
389
390    // Check for missing keys and variables
391    for (key, key_info) in ctx.expected_keys {
392        let Some((actual_vars, _file_path, header_link)) = all_actual_keys.get(key) else {
393            // Key is missing from all files - report it in the first file as a reasonable default
394            let default_file_path = if let Some((_, path, link)) = all_actual_keys.values().next() {
395                (path.clone(), link.clone())
396            } else {
397                // No files at all, this case should be handled earlier but let's provide a fallback
398                (format!("{}.ftl", "unknown"), format!("{}.ftl", "unknown"))
399            };
400
401            issues.push(ValidationIssue::MissingKey(MissingKeyError {
402                src: NamedSource::new(default_file_path.1, String::new()),
403                key: key.clone(),
404                locale: locale.to_string(),
405                help: format!("Add translation for '{}' in {}", key, default_file_path.0),
406            }));
407            continue;
408        };
409
410        // Check for missing variables
411        for var in &key_info.variables {
412            if actual_vars.contains(var) {
413                continue;
414            }
415
416            // Find the span in the actual file (this is approximate)
417            let span = SourceSpan::new(0_usize.into(), 1_usize);
418
419            // Build help message with source location if available
420            let help = match (&key_info.source_file, key_info.source_line) {
421                (Some(file), Some(line)) => {
422                    let file_path = Path::new(file);
423                    let abs_file = if file_path.is_absolute() {
424                        file_path.to_path_buf()
425                    } else {
426                        ctx.manifest_dir.join(file_path)
427                    };
428
429                    let rel_file = to_relative_path(&abs_file, ctx.workspace_root);
430                    let file_label = format!("{rel_file}:{line}");
431                    let file_url = format!("file://{}", abs_file.display());
432                    let file_link = Link::new(&file_label, &file_url);
433
434                    format!("Variable '${var}' is declared at {file_link}")
435                },
436                (Some(file), None) => {
437                    let file_path = Path::new(file);
438                    let abs_file = if file_path.is_absolute() {
439                        file_path.to_path_buf()
440                    } else {
441                        ctx.manifest_dir.join(file_path)
442                    };
443                    let rel_file = to_relative_path(&abs_file, ctx.workspace_root);
444
445                    let file_url = format!("file://{}", abs_file.display());
446                    let file_link = Link::new(&rel_file, &file_url);
447
448                    format!("Variable '${var}' is declared in {file_link}")
449                },
450                _ => format!("Variable '${var}' is declared in Rust code"),
451            };
452
453            issues.push(ValidationIssue::MissingVariable(MissingVariableWarning {
454                src: NamedSource::new(header_link.clone(), String::new()),
455                span,
456                variable: var.clone(),
457                key: key.clone(),
458                locale: locale.to_string(),
459                help,
460            }));
461        }
462    }
463
464    issues
465}
466
467/// Helper to make a path relative to a base path (e.g. workspace root).
468fn to_relative_path(path: &Path, base: &Path) -> String {
469    // Try to canonicalize both for accurate diffing
470    let path_canon = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
471    let base_canon = fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
472
473    // Try to strip prefix
474    if let Ok(rel) = path_canon.strip_prefix(&base_canon) {
475        return rel.display().to_string();
476    }
477
478    // If straightforward strip failed, we can return the path as is or try simple path strip
479    // (sometimes canonicalize fails or resolves symlinks unpredictably)
480    if let Ok(rel) = path.strip_prefix(base) {
481        return rel.display().to_string();
482    }
483
484    // Fallback: return absolute path or best effort
485    path.display().to_string()
486}