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 = 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 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 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 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
216fn validate_crate(
218 krate: &CrateInfo,
219 workspace_root: &Path,
220 temp_dir: &Path,
221 check_all: bool,
222) -> Result<Vec<ValidationIssue>> {
223 let expected_keys = read_inventory_file(temp_dir, &krate.name)?;
225
226 validate_ftl_files(krate, workspace_root, &expected_keys, check_all)
228}
229
230fn 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 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
261fn 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 match discover_and_load_ftl_files(&assets_dir, locale, &krate.name) {
284 Ok(loaded_files) => {
285 if loaded_files.is_empty() {
286 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 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 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
336fn 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
356fn 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(); 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 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 all_actual_keys.insert(
383 key.clone(),
384 (vars, ftl_relative_path.clone(), ftl_header_link.clone()),
385 );
386 }
387 }
388 }
389
390 for (key, key_info) in ctx.expected_keys {
392 let Some((actual_vars, _file_path, header_link)) = all_actual_keys.get(key) else {
393 let default_file_path = if let Some((_, path, link)) = all_actual_keys.values().next() {
395 (path.clone(), link.clone())
396 } else {
397 (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 for var in &key_info.variables {
412 if actual_vars.contains(var) {
413 continue;
414 }
415
416 let span = SourceSpan::new(0_usize.into(), 1_usize);
418
419 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
467fn to_relative_path(path: &Path, base: &Path) -> String {
469 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 if let Ok(rel) = path_canon.strip_prefix(&base_canon) {
475 return rel.display().to_string();
476 }
477
478 if let Ok(rel) = path.strip_prefix(base) {
481 return rel.display().to_string();
482 }
483
484 path.display().to_string()
486}