1use 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#[derive(Deserialize)]
34struct ExpectedKey {
35 key: String,
36 variables: Vec<String>,
37 source_file: Option<String>,
39 source_line: Option<u32>,
41}
42
43#[derive(Clone)]
45struct KeyInfo {
46 variables: HashSet<String>,
47 source_file: Option<String>,
48 source_line: Option<u32>,
49}
50
51#[derive(Deserialize)]
53struct InventoryData {
54 expected_keys: Vec<ExpectedKey>,
55}
56
57#[derive(Debug, Parser)]
59pub struct CheckArgs {
60 #[command(flatten)]
61 pub workspace: WorkspaceArgs,
62
63 #[arg(long)]
65 pub all: bool,
66
67 #[arg(long, value_delimiter = ',')]
70 pub ignore: Vec<String>,
71
72 #[arg(long)]
74 pub force_run: bool,
75}
76
77struct ValidationContext<'a> {
79 expected_keys: &'a IndexMap<String, KeyInfo>,
80 workspace_root: &'a Path,
81 manifest_dir: &'a Path,
82}
83
84pub 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 let ignore_crates: HashSet<String> = args.ignore.into_iter().collect();
95 let force_run = args.force_run;
96
97 let crates_to_check: Vec<_> = workspace
99 .valid
100 .iter()
101 .filter(|k| !ignore_crates.contains(&k.name))
102 .collect();
103
104 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 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_runner_crate(&workspace.workspace_info)
136 .map_err(|e| CliError::Other(e.to_string()))?;
137
138 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 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 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 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
217fn validate_crate(
219 krate: &CrateInfo,
220 workspace_root: &Path,
221 temp_dir: &Path,
222 check_all: bool,
223) -> Result<Vec<ValidationIssue>> {
224 let expected_keys = read_inventory_file(temp_dir, &krate.name)?;
226
227 validate_ftl_files(krate, workspace_root, &expected_keys, check_all)
229}
230
231fn 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 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
259fn 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 match discover_and_load_ftl_files(&assets_dir, locale, &krate.name) {
282 Ok(loaded_files) => {
283 if loaded_files.is_empty() {
284 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 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 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
334fn 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
354fn 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(); 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 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 all_actual_keys.insert(
381 key.clone(),
382 (vars, ftl_relative_path.clone(), ftl_header_link.clone()),
383 );
384 }
385 }
386 }
387
388 for (key, key_info) in ctx.expected_keys {
390 let Some((actual_vars, _file_path, header_link)) = all_actual_keys.get(key) else {
391 let default_file_path = if let Some((_, path, link)) = all_actual_keys.values().next() {
393 (path.clone(), link.clone())
394 } else {
395 (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 for var in &key_info.variables {
410 if actual_vars.contains(var) {
411 continue;
412 }
413
414 let span = SourceSpan::new(0_usize.into(), 1_usize);
416
417 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
465fn to_relative_path(path: &Path, base: &Path) -> String {
467 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 if let Ok(rel) = path_canon.strip_prefix(&base_canon) {
473 return rel.display().to_string();
474 }
475
476 if let Ok(rel) = path.strip_prefix(base) {
479 return rel.display().to_string();
480 }
481
482 path.display().to_string()
484}