1use 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#[derive(Deserialize)]
35struct ExpectedKey {
36 key: String,
37 variables: Vec<String>,
38 source_file: Option<String>,
40 source_line: Option<u32>,
42}
43
44#[derive(Clone)]
46struct KeyInfo {
47 variables: HashSet<String>,
48 source_file: Option<String>,
49 source_line: Option<u32>,
50}
51
52#[derive(Deserialize)]
54struct InventoryData {
55 expected_keys: Vec<ExpectedKey>,
56}
57
58#[derive(Debug, Parser)]
60pub struct CheckArgs {
61 #[command(flatten)]
62 pub workspace: WorkspaceArgs,
63
64 #[arg(long)]
66 pub all: bool,
67
68 #[arg(long, value_delimiter = ',')]
71 pub ignore: Vec<String>,
72
73 #[arg(long)]
75 pub force_run: bool,
76}
77
78struct ValidationContext<'a> {
80 expected_keys: &'a IndexMap<String, KeyInfo>,
81 workspace_root: &'a Path,
82 manifest_dir: &'a Path,
83}
84
85pub 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 let ignore_crates: HashSet<String> = args.ignore.into_iter().collect();
96 let force_run = args.force_run;
97
98 let crates_to_check: Vec<_> = workspace
100 .valid
101 .iter()
102 .filter(|k| !ignore_crates.contains(&k.name))
103 .collect();
104
105 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 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_runner_crate(&workspace.workspace_info)
137 .map_err(|e| CliError::Other(e.to_string()))?;
138
139 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 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 = 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 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
262enum LocaleLoadResult {
264 NotFound,
266 ReadError(String),
268 Loaded {
270 content: String,
271 resource: ast::Resource<String>,
272 parse_errors: Vec<ParserError>,
273 },
274}
275
276fn 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
301fn 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
373fn 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
393fn 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 let ftl_abs_path = ctx.workspace_root.join(file_name);
408 let ftl_header_url = format!("file://{}", ftl_abs_path.display());
409 let ftl_header_link = Link::new(file_name, &ftl_header_url).to_string();
411
412 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 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 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 for (key, key_info) in ctx.expected_keys {
446 if keys_with_syntax_errors.contains(key) {
448 continue;
449 }
450
451 let Some(actual_vars) = actual_keys.get(key) else {
452 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 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 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 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
524fn 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 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 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
555fn 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
565fn to_relative_path(path: &Path, base: &Path) -> String {
567 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 if let Ok(rel) = path_canon.strip_prefix(&base_canon) {
573 return rel.display().to_string();
574 }
575
576 if let Ok(rel) = path.strip_prefix(base) {
579 return rel.display().to_string();
580 }
581
582 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}