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 miette::{NamedSource, SourceSpan};
24use regex::Regex;
25use serde::Deserialize;
26use std::collections::{HashMap, HashSet};
27use std::fs;
28use std::path::Path;
29use std::sync::LazyLock;
30
31#[derive(Deserialize)]
33struct ExpectedKey {
34 key: String,
35 variables: Vec<String>,
36}
37
38#[derive(Deserialize)]
40struct InventoryData {
41 expected_keys: Vec<ExpectedKey>,
42}
43
44#[derive(Debug, Parser)]
46pub struct CheckArgs {
47 #[command(flatten)]
48 pub workspace: WorkspaceArgs,
49
50 #[arg(long)]
52 pub all: bool,
53
54 #[arg(long, value_delimiter = ',')]
57 pub ignore: Vec<String>,
58}
59
60pub fn run_check(args: CheckArgs) -> Result<(), CliError> {
62 let workspace = WorkspaceCrates::discover(args.workspace)?;
63
64 if !workspace.print_discovery(ui::print_check_header) {
65 ui::print_no_crates_found();
66 return Ok(());
67 }
68
69 let ignore_keys: HashSet<String> = args.ignore.into_iter().collect();
71
72 prepare_monolithic_runner_crate(&workspace.workspace_info)
74 .map_err(|e| CliError::Other(e.to_string()))?;
75
76 let temp_dir = workspace.workspace_info.root_dir.join(".es-fluent");
78 let mut all_known_keys: HashSet<String> = HashSet::new();
79
80 let pb = ui::create_progress_bar(workspace.valid.len() as u64, "Collecting keys...");
81
82 for krate in &workspace.valid {
83 pb.set_message(format!("Scanning {}", krate.name));
84 run_monolithic(&workspace.workspace_info, "check", &krate.name, &[])
85 .map_err(|e| CliError::Other(e.to_string()))?;
86 if let Ok(expected_keys) = read_inventory_file(&temp_dir, &krate.name) {
87 all_known_keys.extend(expected_keys.into_keys());
88 }
89 pb.inc(1);
90 }
91
92 pb.finish_and_clear();
93
94 if !ignore_keys.is_empty() {
96 let mut unknown_keys: Vec<&String> = ignore_keys
97 .iter()
98 .filter(|k| !all_known_keys.contains(*k))
99 .collect();
100
101 if !unknown_keys.is_empty() {
102 unknown_keys.sort();
104
105 return Err(CliError::Other(format!(
106 "Unknown keys passed to --ignore: {}",
107 unknown_keys
108 .iter()
109 .map(|k| format!("'{}'", k))
110 .collect::<Vec<_>>()
111 .join(", ")
112 )));
113 }
114 }
115
116 let mut all_issues: Vec<ValidationIssue> = Vec::new();
118
119 let pb = ui::create_progress_bar(workspace.valid.len() as u64, "Checking crates...");
120
121 for krate in &workspace.valid {
122 pb.set_message(format!("Checking {}", krate.name));
123
124 match validate_crate(krate, &temp_dir, args.all, &ignore_keys) {
125 Ok(issues) => {
126 all_issues.extend(issues);
127 },
128 Err(e) => {
129 pb.suspend(|| {
131 ui::print_check_error(&krate.name, &e.to_string());
132 });
133 },
134 }
135 pb.inc(1);
136 }
137
138 pb.finish_and_clear();
139
140 let error_count = all_issues
141 .iter()
142 .filter(|i| {
143 matches!(
144 i,
145 ValidationIssue::MissingKey(_) | ValidationIssue::SyntaxError(_)
146 )
147 })
148 .count();
149 let warning_count = all_issues
150 .iter()
151 .filter(|i| matches!(i, ValidationIssue::MissingVariable(_)))
152 .count();
153
154 if all_issues.is_empty() {
155 ui::print_check_success();
156 Ok(())
157 } else {
158 Err(CliError::Validation(ValidationReport {
159 error_count,
160 warning_count,
161 issues: all_issues,
162 }))
163 }
164}
165
166fn validate_crate(
168 krate: &CrateInfo,
169 temp_dir: &Path,
170 check_all: bool,
171 ignore_keys: &HashSet<String>,
172) -> Result<Vec<ValidationIssue>> {
173 let mut expected_keys = read_inventory_file(temp_dir, &krate.name)?;
175
176 for key in ignore_keys {
178 expected_keys.remove(key);
179 }
180
181 validate_ftl_files(krate, &expected_keys, check_all)
183}
184
185fn read_inventory_file(
187 temp_dir: &std::path::Path,
188 crate_name: &str,
189) -> Result<HashMap<String, HashSet<String>>> {
190 let inventory_path = temp_dir
191 .join("metadata")
192 .join(crate_name)
193 .join("inventory.json");
194 let json_str = fs::read_to_string(&inventory_path)
195 .with_context(|| format!("Failed to read {}", inventory_path.display()))?;
196
197 let data: InventoryData =
198 serde_json::from_str(&json_str).context("Failed to parse inventory JSON")?;
199
200 let mut expected_keys = HashMap::new();
202 for key_info in data.expected_keys {
203 expected_keys.insert(key_info.key, key_info.variables.into_iter().collect());
204 }
205
206 Ok(expected_keys)
207}
208
209enum LocaleLoadResult {
211 NotFound,
213 ReadError(String),
215 Loaded {
217 content: String,
218 resource: ast::Resource<String>,
219 parse_errors: Vec<ParserError>,
220 },
221}
222
223fn load_locale_ftl(assets_dir: &Path, locale: &str, crate_name: &str) -> LocaleLoadResult {
225 let ftl_file = assets_dir.join(locale).join(format!("{}.ftl", crate_name));
226
227 if !ftl_file.exists() {
228 return LocaleLoadResult::NotFound;
229 }
230
231 let content = match fs::read_to_string(&ftl_file) {
232 Ok(c) => c,
233 Err(e) => return LocaleLoadResult::ReadError(e.to_string()),
234 };
235
236 let (resource, parse_errors) = match parser::parse(content.clone()) {
237 Ok(res) => (res, vec![]),
238 Err((res, errors)) => (res, errors),
239 };
240
241 LocaleLoadResult::Loaded {
242 content,
243 resource,
244 parse_errors,
245 }
246}
247
248fn validate_ftl_files(
250 krate: &CrateInfo,
251 expected_keys: &HashMap<String, HashSet<String>>,
252 check_all: bool,
253) -> Result<Vec<ValidationIssue>> {
254 let config = I18nConfig::read_from_path(&krate.i18n_config_path)
255 .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
256
257 let assets_dir = krate.manifest_dir.join(&config.assets_dir);
258
259 let locales: Vec<String> = if check_all {
260 get_all_locales(&assets_dir)?
261 } else {
262 vec![config.fallback_language.clone()]
263 };
264
265 let mut issues = Vec::new();
266
267 for locale in &locales {
268 let file_name = format!("{}/{}.ftl", locale, krate.name);
269
270 match load_locale_ftl(&assets_dir, locale, &krate.name) {
271 LocaleLoadResult::NotFound => {
272 issues.extend(missing_file_issues(expected_keys, locale, &krate.name));
273 continue;
274 },
275 LocaleLoadResult::ReadError(err) => {
276 issues.push(ValidationIssue::SyntaxError(FtlSyntaxError {
277 src: NamedSource::new(file_name.clone(), String::new()),
278 span: SourceSpan::new(0_usize.into(), 1_usize),
279 locale: locale.clone(),
280 file_name,
281 help: format!("Failed to read file: {}", err),
282 }));
283 continue;
284 },
285 LocaleLoadResult::Loaded {
286 content,
287 resource,
288 parse_errors,
289 } => {
290 issues.extend(validate_loaded_ftl(
291 &content,
292 &resource,
293 &parse_errors,
294 expected_keys,
295 locale,
296 &file_name,
297 &krate.name,
298 ));
299 },
300 }
301 }
302
303 Ok(issues)
304}
305
306fn missing_file_issues(
308 expected_keys: &HashMap<String, HashSet<String>>,
309 locale: &str,
310 crate_name: &str,
311) -> Vec<ValidationIssue> {
312 expected_keys
313 .keys()
314 .map(|key| {
315 ValidationIssue::MissingKey(MissingKeyError {
316 src: NamedSource::new(format!("{}/{}.ftl", locale, crate_name), String::new()),
317 key: key.clone(),
318 locale: locale.to_string(),
319 help: format!(
320 "Add translation for '{}' in {}/{}.ftl",
321 key, locale, crate_name
322 ),
323 })
324 })
325 .collect()
326}
327
328fn validate_loaded_ftl(
330 content: &str,
331 resource: &ast::Resource<String>,
332 parse_errors: &[ParserError],
333 expected_keys: &HashMap<String, HashSet<String>>,
334 locale: &str,
335 file_name: &str,
336 crate_name: &str,
337) -> Vec<ValidationIssue> {
338 let mut issues = Vec::new();
339 let mut keys_with_syntax_errors: HashSet<String> = HashSet::new();
340
341 for err in parse_errors {
343 issues.push(parser_error_to_issue(
344 err,
345 content,
346 locale,
347 file_name,
348 &mut keys_with_syntax_errors,
349 ));
350 }
351
352 for entry in &resource.body {
354 if let ast::Entry::Junk { content: junk } = entry
355 && let Some(key) = extract_key_from_junk(junk)
356 {
357 keys_with_syntax_errors.insert(key);
358 }
359 }
360
361 let actual_keys: HashMap<String, HashSet<String>> = resource
363 .body
364 .iter()
365 .filter_map(|entry| match entry {
366 ast::Entry::Message(msg) => {
367 Some((msg.id.name.clone(), extract_variables_from_message(msg)))
368 },
369 _ => None,
370 })
371 .collect();
372
373 for (key, expected_vars) in expected_keys {
375 if keys_with_syntax_errors.contains(key) {
377 continue;
378 }
379
380 let Some(actual_vars) = actual_keys.get(key) else {
381 issues.push(ValidationIssue::MissingKey(MissingKeyError {
383 src: NamedSource::new(file_name, content.to_string()),
384 key: key.clone(),
385 locale: locale.to_string(),
386 help: format!(
387 "Add translation for '{}' in {}/{}.ftl",
388 key, locale, crate_name
389 ),
390 }));
391 continue;
392 };
393
394 for var in expected_vars {
396 if actual_vars.contains(var) {
397 continue;
398 }
399 let span = find_key_span(content, key)
400 .unwrap_or_else(|| SourceSpan::new(0_usize.into(), 1_usize));
401
402 issues.push(ValidationIssue::MissingVariable(MissingVariableWarning {
403 src: NamedSource::new(file_name, content.to_string()),
404 span,
405 variable: var.clone(),
406 key: key.clone(),
407 locale: locale.to_string(),
408 help: format!(
409 "The Rust code generated by es-fluent declares variable '${}' but the translation omits it",
410 var
411 ),
412 }));
413 }
414 }
415
416 issues
417}
418
419fn parser_error_to_issue(
421 err: &ParserError,
422 content: &str,
423 locale: &str,
424 file_name: &str,
425 keys_with_syntax_errors: &mut HashSet<String>,
426) -> ValidationIssue {
427 if let Some(ref slice) = err.slice {
429 let junk_content = &content[slice.clone()];
430 if let Some(key) = extract_key_from_junk(junk_content) {
431 keys_with_syntax_errors.insert(key);
432 }
433 }
434
435 let span_len = if err.pos.end > err.pos.start {
437 err.pos.end - err.pos.start
438 } else {
439 1
440 };
441
442 ValidationIssue::SyntaxError(FtlSyntaxError {
443 src: NamedSource::new(file_name, content.to_string()),
444 span: SourceSpan::new(err.pos.start.into(), span_len),
445 locale: locale.to_string(),
446 file_name: file_name.to_string(),
447 help: err.kind.to_string(),
448 })
449}
450
451fn extract_key_from_junk(junk: &str) -> Option<String> {
454 static KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+").unwrap());
455
456 KEY_REGEX
457 .find(junk.trim_start())
458 .map(|m| m.as_str().to_string())
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 #[test]
466 fn test_find_key_span() {
467 let source = "## Comment\nhello = Hello\nworld = World";
468 let span = find_key_span(source, "hello").unwrap();
469 assert_eq!(span.offset(), 11);
470 assert_eq!(span.len(), 5);
471 }
472
473 #[test]
474 fn test_find_key_span_not_found() {
475 let source = "hello = Hello";
476 let span = find_key_span(source, "goodbye");
477 assert!(span.is_none());
478 }
479
480 #[test]
481 fn test_extract_key_from_junk() {
482 assert_eq!(
483 extract_key_from_junk("my-key = some value"),
484 Some("my-key".to_string())
485 );
486 assert_eq!(
487 extract_key_from_junk(" spaced-key = value"),
488 Some("spaced-key".to_string())
489 );
490 assert_eq!(extract_key_from_junk("# comment"), None);
491 assert_eq!(extract_key_from_junk(""), None);
492 }
493
494 #[test]
495 fn test_extract_variables() {
496 let content = "hello = Hello { $name }, you have { $count } messages";
497 let resource = parser::parse(content.to_string()).unwrap();
498
499 if let ast::Entry::Message(msg) = &resource.body[0] {
500 let vars = extract_variables_from_message(msg);
501 assert!(vars.contains("name"));
502 assert!(vars.contains("count"));
503 assert_eq!(vars.len(), 2);
504 } else {
505 panic!("Expected a message");
506 }
507 }
508}