use anyhow::{Context, Result};
use glob::glob;
use serde_json::{Map, Value, json};
use std::collections::BTreeMap;
use std::path::PathBuf;
use crate::formatters::Formatter;
#[derive(Clone, Copy, Debug)]
pub enum PseudoLocale {
XxLs,
XxAc,
XxHa,
EnXa,
EnXb,
}
#[allow(clippy::too_many_arguments)]
pub fn compile(
translation_files: &[PathBuf],
format: Option<Formatter>,
out_file: Option<&PathBuf>,
ast: bool,
skip_errors: bool,
pseudo_locale: Option<PseudoLocale>,
ignore_tag: bool,
) -> Result<()> {
use formatjs_icu_messageformat_parser::{Parser, ParserOptions};
if pseudo_locale.is_some() && !ast {
anyhow::bail!("Pseudo-locale generation requires --ast flag");
}
if pseudo_locale.is_some() {
eprintln!("Warning: Pseudo-locale transformations not yet implemented");
}
let formatter = format.unwrap_or(Formatter::Default);
let mut expanded_files = Vec::new();
for pattern in translation_files {
let pattern_str = pattern
.to_str()
.context("Pattern path contains invalid UTF-8")?;
match glob(pattern_str) {
Ok(paths) => {
for entry in paths {
match entry {
Ok(path) => expanded_files.push(path),
Err(e) => eprintln!("Warning: Failed to read glob entry: {}", e),
}
}
}
Err(e) => {
eprintln!("Warning: Invalid glob pattern '{}': {}", pattern_str, e);
expanded_files.push(pattern.clone());
}
}
}
if expanded_files.is_empty() {
anyhow::bail!("No translation files found matching the patterns");
}
let mut messages: BTreeMap<String, (String, PathBuf)> = BTreeMap::new();
for file in &expanded_files {
let content = std::fs::read_to_string(file)
.with_context(|| format!("Failed to read file: {}", file.display()))?;
let json: Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON in file: {}", file.display()))?;
let file_path_str = file.to_str().unwrap_or("<invalid path>");
let file_messages = formatter.apply(&json, file_path_str)?;
for (key, message_str) in file_messages {
if let Some((existing, existing_file)) = messages.get(&key) {
if existing != &message_str {
anyhow::bail!(
"Conflicting ID \"{}\" with different translation found in these 2 files:\n {}\n {}",
key,
existing_file.display(),
file.display()
);
}
}
messages.insert(key, (message_str, file.clone()));
}
}
if messages.is_empty() {
eprintln!("Warning: No messages found in translation files");
}
let mut compiled_messages: Map<String, Value> = Map::new();
let mut error_count = 0;
let parser_options = ParserOptions {
ignore_tag,
should_parse_skeletons: true,
requires_other_clause: true,
..Default::default()
};
for (id, (message, source_file)) in &messages {
let parser = Parser::new(message.as_str(), parser_options.clone());
match parser.parse() {
Ok(msg_ast) => {
if ast {
let ast_json = serde_json::to_value(&msg_ast)
.with_context(|| format!("Failed to serialize AST for message '{}'", id))?;
compiled_messages.insert(id.clone(), ast_json);
} else {
compiled_messages.insert(id.clone(), json!(message));
}
}
Err(e) => {
error_count += 1;
if skip_errors {
eprintln!(
"[@formatjs/cli] [WARN] Error validating message \"{}\" with ID \"{}\" in file {}",
message,
id,
source_file.display()
);
} else {
anyhow::bail!("SyntaxError: {}", e);
}
}
}
}
if error_count > 0 {
eprintln!("\nSkipped {} message(s) with parsing errors", error_count);
}
let output_json = Value::Object(compiled_messages);
let output = serde_json::to_string_pretty(&output_json)
.context("Failed to serialize compiled messages to JSON")?;
if let Some(out_path) = out_file {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create parent directories for {}",
out_path.display()
)
})?;
}
std::fs::write(out_path, format!("{}\n", output))
.with_context(|| format!("Failed to write output to {}", out_path.display()))?;
} else {
println!("{}", output);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_compile_simple_messages() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"greeting": "Hello {name}!",
"farewell": "Goodbye!"
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
Some(Formatter::Simple),
Some(&output_file),
false, false, None, false, )
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(output_json["greeting"], "Hello {name}!");
assert_eq!(output_json["farewell"], "Goodbye!");
}
#[test]
fn test_compile_with_default_formatter() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"greeting": {
"defaultMessage": "Hello {name}!",
"description": "Greeting message"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
Some(Formatter::Default),
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(output_json["greeting"], "Hello {name}!");
}
#[test]
fn test_compile_to_ast() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"greeting": {
"defaultMessage": "Hello {name}!"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
None,
Some(&output_file),
true, false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert!(output_json["greeting"].is_array());
}
#[test]
fn test_compile_invalid_icu_message() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"invalid": {
"defaultMessage": "Hello {name" }
})
.to_string(),
)
.unwrap();
let result = compile(
&[input_file.clone()],
None,
Some(&output_file),
false,
false, None,
false,
);
assert!(result.is_err());
}
#[test]
fn test_compile_skip_errors() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"valid": {
"defaultMessage": "Hello {name}!"
},
"invalid": {
"defaultMessage": "Hello {name" }
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
None,
Some(&output_file),
false,
true, None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(output_json["valid"], "Hello {name}!");
assert!(output_json.get("invalid").is_none());
}
#[test]
fn test_compile_multiple_files() {
let dir = tempdir().unwrap();
let input_file1 = dir.path().join("messages1.json");
let input_file2 = dir.path().join("messages2.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file1,
json!({"greeting": {"defaultMessage": "Hello!"}}).to_string(),
)
.unwrap();
fs::write(
&input_file2,
json!({"farewell": {"defaultMessage": "Goodbye!"}}).to_string(),
)
.unwrap();
compile(
&[input_file1, input_file2],
None,
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(output_json["greeting"], "Hello!");
assert_eq!(output_json["farewell"], "Goodbye!");
}
#[test]
fn test_compile_conflict_detection() {
let dir = tempdir().unwrap();
let input_file1 = dir.path().join("messages1.json");
let input_file2 = dir.path().join("messages2.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file1,
json!({"greeting": {"defaultMessage": "Hello!"}}).to_string(),
)
.unwrap();
fs::write(
&input_file2,
json!({"greeting": {"defaultMessage": "Bonjour!"}}).to_string(),
)
.unwrap();
let result = compile(
&[input_file1, input_file2],
None,
Some(&output_file),
false,
false,
None,
false,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Conflict"));
}
#[test]
fn test_compile_with_glob_pattern() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("en.json"),
json!({"greeting": {"defaultMessage": "Hello!"}}).to_string(),
)
.unwrap();
fs::write(
dir.path().join("fr.json"),
json!({"farewell": {"defaultMessage": "Au revoir!"}}).to_string(),
)
.unwrap();
let output_file = dir.path().join("compiled.json");
let pattern = dir.path().join("*.json");
compile(
&[pattern],
None,
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(output_json["greeting"], "Hello!");
assert_eq!(output_json["farewell"], "Au revoir!");
}
#[test]
fn test_compile_icu_plural() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"items": {
"defaultMessage": "{count, plural, one {# item} other {# items}}"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
None,
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(
output_json["items"],
"{count, plural, one {# item} other {# items}}"
);
}
#[test]
fn test_compile_with_transifex_formatter() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"greeting": {
"string": "Hello {name}!",
"developer_comment": "Greeting"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
Some(Formatter::Transifex),
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(output_json["greeting"], "Hello {name}!");
}
#[test]
fn test_compile_pseudo_locale_requires_ast() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(&input_file, json!({"msg": "Hello!"}).to_string()).unwrap();
let result = compile(
&[input_file],
None,
Some(&output_file),
false, false,
Some(PseudoLocale::EnXa), false,
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("requires --ast flag")
);
}
#[test]
fn test_compile_sorted_keys() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"zebra": {
"defaultMessage": "Zebra message"
},
"apple": {
"defaultMessage": "Apple message"
},
"mango": {
"defaultMessage": "Mango message"
},
"banana": {
"defaultMessage": "Banana message"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
None,
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
let keys: Vec<&str> = output_json
.as_object()
.unwrap()
.keys()
.map(|s| s.as_str())
.collect();
let mut sorted_keys = keys.clone();
sorted_keys.sort();
assert_eq!(keys, sorted_keys, "Keys should be sorted alphabetically");
assert_eq!(keys, vec!["apple", "banana", "mango", "zebra"]);
}
#[test]
fn test_compile_sorted_keys_multiple_files() {
let dir = tempdir().unwrap();
let input_file1 = dir.path().join("messages1.json");
let input_file2 = dir.path().join("messages2.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file1,
json!({
"zebra": {"defaultMessage": "Zebra!"},
"delta": {"defaultMessage": "Delta!"}
})
.to_string(),
)
.unwrap();
fs::write(
&input_file2,
json!({
"alpha": {"defaultMessage": "Alpha!"},
"charlie": {"defaultMessage": "Charlie!"}
})
.to_string(),
)
.unwrap();
compile(
&[input_file1, input_file2],
None,
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
let keys: Vec<&str> = output_json
.as_object()
.unwrap()
.keys()
.map(|s| s.as_str())
.collect();
assert_eq!(keys, vec!["alpha", "charlie", "delta", "zebra"]);
}
#[test]
fn test_compile_sorted_keys_with_ast() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"zulu": {
"defaultMessage": "Zulu {name}!"
},
"bravo": {
"defaultMessage": "Bravo {count}!"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
None,
Some(&output_file),
true, false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
let keys: Vec<&str> = output_json
.as_object()
.unwrap()
.keys()
.map(|s| s.as_str())
.collect();
assert_eq!(keys, vec!["bravo", "zulu"]);
}
#[test]
fn test_compile_with_crowdin_formatter() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"greeting": {
"message": "Hello {name}!",
"description": "Greeting message shown to users"
},
"farewell": {
"message": "Goodbye {name}!",
"description": "Farewell message"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
Some(Formatter::Crowdin),
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(output_json["greeting"], "Hello {name}!");
assert_eq!(output_json["farewell"], "Goodbye {name}!");
}
#[test]
fn test_compile_with_crowdin_formatter_icu_plural() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"items_count": {
"message": "{count, plural, one {# item} other {# items}}",
"description": "Shows the number of items"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
Some(Formatter::Crowdin),
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(
output_json["items_count"],
"{count, plural, one {# item} other {# items}}"
);
}
#[test]
fn test_compile_with_crowdin_formatter_skips_smartling_key() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"smartling": {
"message": "This should be skipped",
"description": "Smartling metadata"
},
"greeting": {
"message": "Hello!",
"description": "Greeting"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
Some(Formatter::Crowdin),
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert!(output_json.get("smartling").is_none());
assert_eq!(output_json["greeting"], "Hello!");
}
#[test]
fn test_compile_with_crowdin_formatter_to_ast() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"greeting": {
"message": "Hello {name}!",
"description": "Greeting message"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
Some(Formatter::Crowdin),
Some(&output_file),
true, false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert!(output_json["greeting"].is_array());
let ast = output_json["greeting"].as_array().unwrap();
assert!(!ast.is_empty());
}
#[test]
fn test_compile_with_crowdin_formatter_missing_message_field() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("messages.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file,
json!({
"greeting": {
"description": "This entry has no message field"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file],
Some(Formatter::Crowdin),
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert!(output_json.as_object().unwrap().is_empty());
}
#[test]
fn test_compile_with_crowdin_formatter_multiple_files() {
let dir = tempdir().unwrap();
let input_file1 = dir.path().join("messages1.json");
let input_file2 = dir.path().join("messages2.json");
let output_file = dir.path().join("compiled.json");
fs::write(
&input_file1,
json!({
"greeting": {
"message": "Hello!",
"description": "Greeting"
}
})
.to_string(),
)
.unwrap();
fs::write(
&input_file2,
json!({
"farewell": {
"message": "Goodbye!",
"description": "Farewell"
}
})
.to_string(),
)
.unwrap();
compile(
&[input_file1, input_file2],
Some(Formatter::Crowdin),
Some(&output_file),
false,
false,
None,
false,
)
.unwrap();
let output_content = fs::read_to_string(&output_file).unwrap();
let output_json: serde_json::Value = serde_json::from_str(&output_content).unwrap();
assert_eq!(output_json["greeting"], "Hello!");
assert_eq!(output_json["farewell"], "Goodbye!");
}
}