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
55pub fn run_check(args: CheckArgs) -> Result<(), CliError> {
57 let workspace = WorkspaceCrates::discover(args.workspace)?;
58
59 if !workspace.print_discovery(ui::print_check_header) {
60 ui::print_no_crates_found();
61 return Ok(());
62 }
63
64 prepare_monolithic_runner_crate(&workspace.workspace_info)
66 .map_err(|e| CliError::Other(e.to_string()))?;
67
68 let mut all_issues: Vec<ValidationIssue> = Vec::new();
69
70 let pb = ui::create_progress_bar(workspace.valid.len() as u64, "Checking crates...");
71
72 for krate in &workspace.valid {
73 pb.set_message(format!("Checking {}", krate.name));
74
75 match check_crate(krate, &workspace.workspace_info, args.all) {
76 Ok(issues) => {
77 all_issues.extend(issues);
78 },
79 Err(e) => {
80 pb.suspend(|| {
82 ui::print_check_error(&krate.name, &e.to_string());
83 });
84 },
85 }
86 pb.inc(1);
87 }
88
89 pb.finish_and_clear();
90
91 let error_count = all_issues
92 .iter()
93 .filter(|i| {
94 matches!(
95 i,
96 ValidationIssue::MissingKey(_) | ValidationIssue::SyntaxError(_)
97 )
98 })
99 .count();
100 let warning_count = all_issues
101 .iter()
102 .filter(|i| matches!(i, ValidationIssue::MissingVariable(_)))
103 .count();
104
105 if all_issues.is_empty() {
106 ui::print_check_success();
107 Ok(())
108 } else {
109 Err(CliError::Validation(ValidationReport {
110 error_count,
111 warning_count,
112 issues: all_issues,
113 }))
114 }
115}
116
117use crate::core::WorkspaceInfo;
118
119fn check_crate(
121 krate: &CrateInfo,
122 workspace: &WorkspaceInfo,
123 check_all: bool,
124) -> Result<Vec<ValidationIssue>> {
125 let temp_dir = workspace.root_dir.join(".es-fluent");
127 run_monolithic(workspace, "check", &krate.name, &[])?;
128 let expected_keys = read_inventory_file(&temp_dir, &krate.name)?;
129
130 validate_ftl_files(krate, &expected_keys, check_all)
132}
133
134fn read_inventory_file(
136 temp_dir: &std::path::Path,
137 crate_name: &str,
138) -> Result<HashMap<String, HashSet<String>>> {
139 let inventory_path = temp_dir
140 .join("metadata")
141 .join(crate_name)
142 .join("inventory.json");
143 let json_str = fs::read_to_string(&inventory_path)
144 .with_context(|| format!("Failed to read {}", inventory_path.display()))?;
145
146 let data: InventoryData =
147 serde_json::from_str(&json_str).context("Failed to parse inventory JSON")?;
148
149 let mut expected_keys = HashMap::new();
151 for key_info in data.expected_keys {
152 expected_keys.insert(key_info.key, key_info.variables.into_iter().collect());
153 }
154
155 Ok(expected_keys)
156}
157
158enum LocaleLoadResult {
160 NotFound,
162 ReadError(String),
164 Loaded {
166 content: String,
167 resource: ast::Resource<String>,
168 parse_errors: Vec<ParserError>,
169 },
170}
171
172fn load_locale_ftl(assets_dir: &Path, locale: &str, crate_name: &str) -> LocaleLoadResult {
174 let ftl_file = assets_dir.join(locale).join(format!("{}.ftl", crate_name));
175
176 if !ftl_file.exists() {
177 return LocaleLoadResult::NotFound;
178 }
179
180 let content = match fs::read_to_string(&ftl_file) {
181 Ok(c) => c,
182 Err(e) => return LocaleLoadResult::ReadError(e.to_string()),
183 };
184
185 let (resource, parse_errors) = match parser::parse(content.clone()) {
186 Ok(res) => (res, vec![]),
187 Err((res, errors)) => (res, errors),
188 };
189
190 LocaleLoadResult::Loaded {
191 content,
192 resource,
193 parse_errors,
194 }
195}
196
197fn validate_ftl_files(
199 krate: &CrateInfo,
200 expected_keys: &HashMap<String, HashSet<String>>,
201 check_all: bool,
202) -> Result<Vec<ValidationIssue>> {
203 let config = I18nConfig::read_from_path(&krate.i18n_config_path)
204 .with_context(|| format!("Failed to read {}", krate.i18n_config_path.display()))?;
205
206 let assets_dir = krate.manifest_dir.join(&config.assets_dir);
207
208 let locales: Vec<String> = if check_all {
209 get_all_locales(&assets_dir)?
210 } else {
211 vec![config.fallback_language.clone()]
212 };
213
214 let mut issues = Vec::new();
215
216 for locale in &locales {
217 let file_name = format!("{}/{}.ftl", locale, krate.name);
218
219 match load_locale_ftl(&assets_dir, locale, &krate.name) {
220 LocaleLoadResult::NotFound => {
221 issues.extend(missing_file_issues(expected_keys, locale, &krate.name));
222 continue;
223 },
224 LocaleLoadResult::ReadError(err) => {
225 issues.push(ValidationIssue::SyntaxError(FtlSyntaxError {
226 src: NamedSource::new(file_name.clone(), String::new()),
227 span: SourceSpan::new(0_usize.into(), 1_usize),
228 locale: locale.clone(),
229 file_name,
230 help: format!("Failed to read file: {}", err),
231 }));
232 continue;
233 },
234 LocaleLoadResult::Loaded {
235 content,
236 resource,
237 parse_errors,
238 } => {
239 issues.extend(validate_loaded_ftl(
240 &content,
241 &resource,
242 &parse_errors,
243 expected_keys,
244 locale,
245 &file_name,
246 &krate.name,
247 ));
248 },
249 }
250 }
251
252 Ok(issues)
253}
254
255fn missing_file_issues(
257 expected_keys: &HashMap<String, HashSet<String>>,
258 locale: &str,
259 crate_name: &str,
260) -> Vec<ValidationIssue> {
261 expected_keys
262 .keys()
263 .map(|key| {
264 ValidationIssue::MissingKey(MissingKeyError {
265 src: NamedSource::new(format!("{}/{}.ftl", locale, crate_name), String::new()),
266 key: key.clone(),
267 locale: locale.to_string(),
268 help: format!(
269 "Add translation for '{}' in {}/{}.ftl",
270 key, locale, crate_name
271 ),
272 })
273 })
274 .collect()
275}
276
277fn validate_loaded_ftl(
279 content: &str,
280 resource: &ast::Resource<String>,
281 parse_errors: &[ParserError],
282 expected_keys: &HashMap<String, HashSet<String>>,
283 locale: &str,
284 file_name: &str,
285 crate_name: &str,
286) -> Vec<ValidationIssue> {
287 let mut issues = Vec::new();
288 let mut keys_with_syntax_errors: HashSet<String> = HashSet::new();
289
290 for err in parse_errors {
292 issues.push(parser_error_to_issue(
293 err,
294 content,
295 locale,
296 file_name,
297 &mut keys_with_syntax_errors,
298 ));
299 }
300
301 for entry in &resource.body {
303 if let ast::Entry::Junk { content: junk } = entry
304 && let Some(key) = extract_key_from_junk(junk)
305 {
306 keys_with_syntax_errors.insert(key);
307 }
308 }
309
310 let actual_keys: HashMap<String, HashSet<String>> = resource
312 .body
313 .iter()
314 .filter_map(|entry| match entry {
315 ast::Entry::Message(msg) => {
316 Some((msg.id.name.clone(), extract_variables_from_message(msg)))
317 },
318 _ => None,
319 })
320 .collect();
321
322 for (key, expected_vars) in expected_keys {
324 if keys_with_syntax_errors.contains(key) {
326 continue;
327 }
328
329 let Some(actual_vars) = actual_keys.get(key) else {
330 issues.push(ValidationIssue::MissingKey(MissingKeyError {
332 src: NamedSource::new(file_name, content.to_string()),
333 key: key.clone(),
334 locale: locale.to_string(),
335 help: format!(
336 "Add translation for '{}' in {}/{}.ftl",
337 key, locale, crate_name
338 ),
339 }));
340 continue;
341 };
342
343 for var in expected_vars {
345 if actual_vars.contains(var) {
346 continue;
347 }
348 let span = find_key_span(content, key)
349 .unwrap_or_else(|| SourceSpan::new(0_usize.into(), 1_usize));
350
351 issues.push(ValidationIssue::MissingVariable(MissingVariableWarning {
352 src: NamedSource::new(file_name, content.to_string()),
353 span,
354 variable: var.clone(),
355 key: key.clone(),
356 locale: locale.to_string(),
357 help: format!(
358 "The Rust code generated by es-fluent declares variable '${}' but the translation omits it",
359 var
360 ),
361 }));
362 }
363 }
364
365 issues
366}
367
368fn parser_error_to_issue(
370 err: &ParserError,
371 content: &str,
372 locale: &str,
373 file_name: &str,
374 keys_with_syntax_errors: &mut HashSet<String>,
375) -> ValidationIssue {
376 if let Some(ref slice) = err.slice {
378 let junk_content = &content[slice.clone()];
379 if let Some(key) = extract_key_from_junk(junk_content) {
380 keys_with_syntax_errors.insert(key);
381 }
382 }
383
384 let span_len = if err.pos.end > err.pos.start {
386 err.pos.end - err.pos.start
387 } else {
388 1
389 };
390
391 ValidationIssue::SyntaxError(FtlSyntaxError {
392 src: NamedSource::new(file_name, content.to_string()),
393 span: SourceSpan::new(err.pos.start.into(), span_len),
394 locale: locale.to_string(),
395 file_name: file_name.to_string(),
396 help: err.kind.to_string(),
397 })
398}
399
400fn extract_key_from_junk(junk: &str) -> Option<String> {
403 static KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+").unwrap());
404
405 KEY_REGEX
406 .find(junk.trim_start())
407 .map(|m| m.as_str().to_string())
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn test_find_key_span() {
416 let source = "## Comment\nhello = Hello\nworld = World";
417 let span = find_key_span(source, "hello").unwrap();
418 assert_eq!(span.offset(), 11);
419 assert_eq!(span.len(), 5);
420 }
421
422 #[test]
423 fn test_find_key_span_not_found() {
424 let source = "hello = Hello";
425 let span = find_key_span(source, "goodbye");
426 assert!(span.is_none());
427 }
428
429 #[test]
430 fn test_extract_key_from_junk() {
431 assert_eq!(
432 extract_key_from_junk("my-key = some value"),
433 Some("my-key".to_string())
434 );
435 assert_eq!(
436 extract_key_from_junk(" spaced-key = value"),
437 Some("spaced-key".to_string())
438 );
439 assert_eq!(extract_key_from_junk("# comment"), None);
440 assert_eq!(extract_key_from_junk(""), None);
441 }
442
443 #[test]
444 fn test_extract_variables() {
445 let content = "hello = Hello { $name }, you have { $count } messages";
446 let resource = parser::parse(content.to_string()).unwrap();
447
448 if let ast::Entry::Message(msg) = &resource.body[0] {
449 let vars = extract_variables_from_message(msg);
450 assert!(vars.contains("name"));
451 assert!(vars.contains("count"));
452 assert_eq!(vars.len(), 2);
453 } else {
454 panic!("Expected a message");
455 }
456 }
457}