use crate::{
config::{
settings::{Settings, SettingsLayer},
v1::{self as config, PathOrGlobPattern},
},
error::{self, Error, FailedWithErrors, IoError, OutputError, Tally},
model,
progress::{Logger, relative_to},
};
use codespan_reporting::diagnostic::{Diagnostic, Label, Severity};
use futures::future::{Future, TryFutureExt};
use futures::stream::{self, StreamExt, TryStreamExt};
use globetrotter_model::{
diagnostics::{DiagnosticExt, FileId, Spanned, ToDiagnostics},
lint::{AllowEntry, LintOptions},
validation::ValidationOptions,
};
use itertools::Itertools;
use normalize_path::NormalizePath;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
#[derive(Debug, Clone, Default)]
pub struct LintParams {
pub detect_duplicates: bool,
pub usages: Vec<PathBuf>,
pub dynamic_usages: Option<crate::config::usages::DynamicUsages>,
pub respect_ignore_files: Option<bool>,
pub respect_gitignore: Option<bool>,
pub llm_judge: Option<LlmJudgeParams>,
}
#[derive(Debug, Clone)]
pub struct LlmJudgeParams {
pub base_url: String,
pub model: String,
pub api_key_env: String,
pub concurrency: usize,
pub temperature: f32,
pub effort: Option<LlmJudgeEffort>,
pub template: Option<String>,
pub min_confidence: f64,
pub cache_dir: Option<PathBuf>,
pub cache_capacity: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LlmJudgeEffort {
Low,
#[default]
Medium,
High,
}
pub(crate) async fn write_to_file(path: &Path, data: impl AsRef<[u8]>) -> Result<(), IoError> {
use tokio::io::AsyncWriteExt;
let err = |source: std::io::Error| IoError::new(path, source);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(err)?;
}
let output_file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)
.await
.map_err(err)?;
let mut writer = tokio::io::BufWriter::new(output_file);
writer.write_all(data.as_ref()).await.map_err(err)?;
writer.flush().await.map_err(err)
}
pub(crate) fn resolve_path(base_dir: Option<&Path>, path: &Path) -> PathBuf {
let output_path = match base_dir {
None => path.to_path_buf(),
Some(_) if path.is_absolute() => path.to_path_buf(),
Some(base_dir) => base_dir.join(path),
};
output_path.normalize()
}
struct Expanded {
pattern: String,
paths: Vec<Result<PathBuf, Error>>,
}
fn expand_pattern(base_dir: Option<&Path>, path_or_glob_pattern: &str) -> Expanded {
let pattern = resolve_path(base_dir, Path::new(path_or_glob_pattern))
.to_string_lossy()
.into_owned();
let options = glob::MatchOptions {
case_sensitive: false,
require_literal_separator: false,
require_literal_leading_dot: false,
};
let paths = match glob::glob_with(&pattern, options) {
Err(source) => vec![Err(Error::Pattern {
source,
path: pattern.clone(),
})],
Ok(entries) => entries
.map(|entry| {
entry.map_err(|source| Error::Glob {
source,
path: pattern.clone(),
})
})
.collect(),
};
Expanded { pattern, paths }
}
pub(crate) fn resolve_input_paths(
base_dir: Option<&Path>,
path_or_glob_pattern: &Spanned<PathOrGlobPattern>,
file_id: Option<FileId>,
strict: bool,
diagnostics: &mut Vec<Diagnostic<FileId>>,
) -> Vec<Result<PathBuf, Error>> {
let Expanded { pattern, paths } = expand_pattern(base_dir, path_or_glob_pattern.as_ref());
if paths.is_empty() {
let mut diagnostic = Diagnostic::warning_or_error(strict)
.with_message(format!("pattern {pattern:?} did not match input"));
if let Some(file_id) = file_id {
diagnostic = diagnostic.with_labels(vec![
Label::primary(file_id, path_or_glob_pattern.span.clone())
.with_message("this file path or glob pattern matched zero files"),
]);
}
diagnostics.push(diagnostic);
if let Some(file_id) = file_id {
let diagnostic = Diagnostic::note().with_labels(vec![
Label::secondary(file_id, path_or_glob_pattern.span.clone())
.with_message(format!("resolves to {pattern:?}")),
]);
diagnostics.push(diagnostic);
}
}
paths
}
type OutputFuture<'a> = Pin<Box<dyn Future<Output = Result<(), OutputError>> + 'a>>;
struct SourceFile {
input: config::Input,
path: PathBuf,
file_id: FileId,
contents: String,
}
struct ParsedFile {
file_id: FileId,
translations: model::Translations,
diagnostics: Vec<Diagnostic<FileId>>,
}
fn key_prefix(input: &config::Input, path: &Path, base_dir: Option<&Path>) -> Vec<String> {
let mut prefix: Vec<String> = Vec::new();
if input
.prepend_relative_path
.as_deref()
.copied()
.unwrap_or(false)
{
if let Some(base_dir) = base_dir
&& let Some(rel_path) = pathdiff::diff_paths(path, base_dir)
{
let mut components: Vec<String> = rel_path
.components()
.filter_map(|c| {
use std::path::Component;
match c {
Component::Normal(os) => Some(os.to_string_lossy().to_string()),
_ => None,
}
})
.collect();
if let Some(last) = components.last_mut()
&& let Some(stripped) = Path::new(last).file_stem()
{
*last = stripped.to_string_lossy().to_string();
}
prefix.extend(components.into_iter().filter(|p| !p.is_empty()));
}
} else if input.prepend_filename.as_deref().copied().unwrap_or(false) {
let file_stem = path
.file_stem()
.map(|name| name.to_string_lossy().to_string());
if let Some(file_stem) = file_stem
&& !file_stem.is_empty()
{
prefix.push(file_stem);
}
}
if let Some(extra_prefix) = input
.prefix
.as_ref()
.map(|prefix| prefix.as_ref().as_str())
.filter(|extra_prefix| !extra_prefix.is_empty())
{
prefix.push(extra_prefix.to_string());
}
prefix
}
fn parse_source_file(file: &SourceFile, base_dir: Option<&Path>, strict: bool) -> ParsedFile {
let mut diagnostics = vec![];
let mut translations =
match model::Translations::from_str(&file.contents, file.file_id, strict, &mut diagnostics)
{
Err(err) => {
diagnostics.extend(err.to_diagnostics(file.file_id));
model::Translations::default()
}
Ok(translations) => translations,
};
let prefix = key_prefix(&file.input, &file.path, base_dir);
if !prefix.is_empty() {
let separator = file
.input
.separator
.as_ref()
.map_or(".", |sep| sep.as_ref().as_str());
translations.0 = translations
.0
.into_iter()
.map(|(key, value)| {
let prefixed_key = prefix
.iter()
.map(String::as_str)
.chain([key.as_ref().as_str()])
.join(separator);
(Spanned::new(key.span, prefixed_key), value)
})
.collect();
}
ParsedFile {
file_id: file.file_id,
translations,
diagnostics,
}
}
fn combine_translations(
files: Vec<ParsedFile>,
diagnostics: &mut Vec<Diagnostic<FileId>>,
) -> model::Translations {
let duplicate_keys = files
.iter()
.flat_map(|file| file.translations.0.keys())
.duplicates();
for duplicate_key in duplicate_keys {
let occurrences = files
.iter()
.flat_map(|file| {
file.translations
.0
.keys()
.filter(|key| key == &duplicate_key)
.map(|key| (key.span.clone(), file.file_id))
})
.collect();
let diagnostic = error::DuplicateKeyError {
key: duplicate_key.as_ref().clone(),
occurrences,
};
diagnostics.extend(diagnostic.to_diagnostics(true));
}
model::Translations(
files
.into_iter()
.flat_map(|file| file.translations.0.into_iter())
.collect(),
)
}
fn assemble_catalog(
files: Vec<ParsedFile>,
max_keys: Option<usize>,
allow: &BTreeSet<AllowEntry>,
diagnostics: &mut Vec<Diagnostic<FileId>>,
) -> model::Translations {
let mut translations = combine_translations(files, diagnostics);
limit_keys(max_keys, &mut translations);
translations.extend_allow(allow);
translations
}
fn limit_keys(max_keys: Option<usize>, translations: &mut model::Translations) {
let Some(max_keys) = max_keys else {
return;
};
let total = translations.0.len();
if total > max_keys {
translations.0.truncate(max_keys);
tracing::warn!(
"processing only the first {max_keys} of {total} translation keys (max-keys limit)"
);
}
}
pub struct Executor {
pub overrides: SettingsLayer,
pub global_base_dir_for_display: Option<PathBuf>,
pub handlebars: handlebars::Handlebars<'static>,
pub diagnostic_printer: crate::diagnostics::Printer,
pub logger: Logger,
pub max_keys: Option<usize>,
}
impl Executor {
#[must_use]
pub fn new<F>(
configs: &config::Configs<F>,
diagnostic_printer: crate::diagnostics::Printer,
) -> Self {
let logger = Logger::new(configs);
Self {
overrides: SettingsLayer::default(),
global_base_dir_for_display: None,
handlebars: handlebars::Handlebars::default(),
diagnostic_printer,
logger,
max_keys: None,
}
}
fn lint_strict(&self) -> bool {
self.overrides.strict.unwrap_or(false)
}
async fn emit_all(&self, diagnostics: &[Diagnostic<FileId>]) -> Result<Tally, Error> {
let mut tally = Tally::default();
for diagnostic in diagnostics {
tally.record(diagnostic.severity);
self.diagnostic_printer.emit(diagnostic).await?;
}
Ok(tally)
}
pub(crate) fn display_path(&self, path: &Path, settings: &Settings) -> String {
if settings.print_absolute_paths {
path.display().to_string()
} else {
relative_to(self.global_base_dir_for_display.as_deref(), path)
.display()
.to_string()
}
}
pub(crate) async fn write_output(
&self,
path: &Path,
contents: &[u8],
settings: &Settings,
) -> Result<String, IoError> {
if settings.dry_run {
return Ok(self.logger.dry_run_would_write(path).to_string());
}
write_to_file(path, contents).await?;
Ok(format!("wrote {}", self.display_path(path, settings)))
}
async fn read_source_file(
&self,
input: config::Input,
path: PathBuf,
) -> Result<SourceFile, Error> {
let path = tokio::fs::canonicalize(&path)
.await
.map_err(|source| IoError::new(path, source))?;
tracing::debug!(path = ?path, "reading translations");
let contents = tokio::fs::read_to_string(&path)
.await
.map_err(|source| IoError::new(&path, source))?;
let source_name = self
.global_base_dir_for_display
.as_ref()
.and_then(|base_dir| pathdiff::diff_paths(&path, base_dir))
.unwrap_or(path.clone());
let file_id = self
.diagnostic_printer
.add_source_file(&source_name, contents.clone())
.await;
Ok(SourceFile {
input,
path,
file_id,
contents,
})
}
fn unique_input_paths<'a>(
inputs: &'a [config::Input],
base_dir: Option<&'a Path>,
strict: bool,
file_id: Option<FileId>,
diagnostics: &'a mut Vec<Diagnostic<FileId>>,
) -> impl Iterator<Item = Result<(config::Input, PathBuf), Error>> + use<'a> {
inputs
.iter()
.flat_map(move |input| {
let included = resolve_input_paths(
base_dir,
&input.path_or_glob_pattern,
file_id,
strict,
diagnostics,
);
let (excluded, errors): (HashSet<PathBuf>, Vec<Error>) = input
.exclude
.iter()
.flat_map(|exclude| expand_pattern(base_dir, exclude.as_ref()).paths)
.partition_result();
errors.into_iter().map(Err).chain(
included
.into_iter()
.filter_ok(move |path| !excluded.contains(path))
.map_ok(|path| (input.clone(), path)),
)
})
.dedup_by(|a, b| match (a, b) {
(Ok(a), Ok(b)) => a == b,
_ => false,
})
}
async fn load_translations(
&self,
config_file: &config::ConfigFile<FileId>,
strict: bool,
diagnostics: &mut Vec<Diagnostic<FileId>>,
) -> Result<Vec<ParsedFile>, Error> {
let inputs: Vec<_> = Self::unique_input_paths(
&config_file.config.inputs,
config_file.config_dir.as_deref(),
strict,
config_file.file_id,
diagnostics,
)
.collect();
stream::iter(inputs)
.map(|input| async {
let (input, path) = input?;
self.read_source_file(input, path).await
})
.buffer_unordered(16)
.and_then(|file| async {
let base_dir = config_file.config_dir.clone();
let parsed = tokio::task::spawn_blocking(move || {
parse_source_file(&file, base_dir.as_deref(), strict)
})
.await?;
Ok(parsed)
})
.try_collect::<Vec<_>>()
.await
}
async fn load_files(
&self,
config_file: &config::ConfigFile<FileId>,
strict: bool,
) -> Result<(Vec<ParsedFile>, Tally), Error> {
let mut diagnostics = vec![];
let mut files = self
.load_translations(config_file, strict, &mut diagnostics)
.await?;
let mut tally = self.emit_all(&diagnostics).await?;
for file in &mut files {
tally += self.emit_all(&file.diagnostics).await?;
file.diagnostics.clear();
}
Ok((files, tally))
}
async fn assemble(
&self,
config_file: &config::ConfigFile<FileId>,
files: Vec<ParsedFile>,
) -> Result<(Arc<model::Translations>, Tally), Error> {
let max_keys = self.max_keys;
let allow = config_file.config.allow.clone();
let (translations, diagnostics) = tokio::task::spawn_blocking(move || {
let mut diagnostics: Vec<Diagnostic<FileId>> = vec![];
let translations = assemble_catalog(files, max_keys, &allow, &mut diagnostics);
(Arc::new(translations), diagnostics)
})
.await?;
let tally = self.emit_all(&diagnostics).await?;
Ok((translations, tally))
}
pub async fn execute_config(
&self,
config_file: Arc<config::ConfigFile<FileId>>,
) -> Result<(), Error> {
tracing::debug!(name = config_file.config.name.as_ref(), "executing");
let settings = Settings::resolve(&config_file.config.settings, &self.overrides);
let (files, tally) = self.load_files(&config_file, settings.strict).await?;
tally.fail_on_errors()?;
let (translations, tally) = self.assemble(&config_file, files).await?;
tally.fail_on_errors()?;
let validate_translations = tokio::task::spawn_blocking({
let translations = Arc::clone(&translations);
let config_file = Arc::clone(&config_file);
let settings = settings.clone();
move || {
let mut diagnostics = vec![];
let options = ValidationOptions {
required_languages: &config_file.config.languages,
template_engine: settings.template_engine.as_ref(),
strict: settings.strict,
check_templates: settings.check_templates,
};
translations.validate(
&config_file.config.name,
config_file.file_id,
&mut diagnostics,
&options,
);
diagnostics
}
});
let output_futures: Vec<OutputFuture<'_>> = vec![
Box::pin(
self.generate_json_outputs(&config_file, &translations, &settings)
.map_err(OutputError::from),
),
#[cfg(feature = "typescript")]
Box::pin(
self.generate_typescript_outputs(&config_file, &translations, &settings)
.map_err(OutputError::from),
),
#[cfg(feature = "rust")]
Box::pin(
self.generate_rust_outputs(&config_file, &translations, &settings)
.map_err(OutputError::from),
),
];
let tally = self.emit_all(&validate_translations.await?).await?;
tally.fail_on_errors()?;
#[cfg(feature = "golang")]
Self::warn_missing_golang_generator(&config_file.config);
#[cfg(feature = "python")]
Self::warn_missing_python_generator(&config_file.config);
futures::future::join_all(output_futures)
.await
.into_iter()
.collect::<Result<(), _>>()?;
Ok(())
}
pub async fn execute(self, configs: config::Configs<FileId>) -> Result<Self, Error> {
tracing::trace!(num_configs = configs.len(), "executing");
stream::iter(configs)
.map(|config_file| async move { Ok(Arc::new(config_file)) })
.buffer_unordered(8)
.try_for_each(|config| async { self.execute_config(config).await })
.await?;
Ok(self)
}
pub async fn lint_config(
&self,
config_file: Arc<config::ConfigFile<FileId>>,
params: &LintParams,
) -> Result<(Tally, Arc<model::Translations>), Error> {
tracing::debug!(name = config_file.config.name.as_ref(), "linting");
let settings = Settings {
strict: self.lint_strict(),
..Settings::resolve(&config_file.config.settings, &self.overrides)
};
let (files, mut tally) = self.load_files(&config_file, settings.strict).await?;
let (translations, assembled) = self.assemble(&config_file, files).await?;
tally += assembled;
let lint_diagnostics = tokio::task::spawn_blocking({
let translations = Arc::clone(&translations);
let config_file = Arc::clone(&config_file);
let detect_duplicates = params.detect_duplicates;
move || {
let mut diagnostics = vec![];
let options = LintOptions {
required_languages: &config_file.config.languages,
template_engine: settings.template_engine.as_ref(),
strict: settings.strict,
detect_duplicates,
};
translations.lint(&mut diagnostics, &options);
diagnostics
}
})
.await?;
tally += self.emit_all(&lint_diagnostics).await?;
Ok((tally, translations))
}
async fn collect_usage_diagnostics(
&self,
config_file: &config::ConfigFile<FileId>,
translations: &model::Translations,
params: &LintParams,
excluded: &BTreeSet<PathBuf>,
usage_diagnostics: &mut UsageDiagnostics,
) -> Result<(), Error> {
let usages = effective_usages(config_file, params);
if !usages.roots.is_empty() {
let defined_keys: Vec<_> = translations
.0
.iter()
.map(|(key, translation)| crate::dead_keys::DefinedKey {
key: key.as_ref().clone(),
generated_identifiers: target_identifiers(&config_file.config, key.as_ref()),
file_id: translation.file_id,
span: key.span.clone(),
allow: translation.allow.clone(),
})
.collect();
let policy = usages.dynamic;
let excluded = excluded.clone();
let scan = tokio::task::spawn_blocking(move || {
crate::dead_keys::scan_config(&defined_keys, &usages, &excluded)
})
.await?
.map_err(|source| IoError::new("<usages>", source))?;
let owner = match config_file.file_id {
Some(file_id) => format!(
"{} ({})",
config_file.config.name.as_ref(),
self.diagnostic_printer.source_name(file_id).await?
),
None => config_file.config.name.as_ref().clone(),
};
for key in scan.unused {
usage_diagnostics.insert(
crate::dead_keys::unused_diagnostic(&key, self.lint_strict()),
&owner,
);
}
let suppress_dynamic = globetrotter_model::lint::is_allowed(
&config_file.config.allow,
globetrotter_model::lint::LintCode::DynamicUsage,
);
if !suppress_dynamic {
for source in scan.dynamic {
let file_id = self
.diagnostic_printer
.add_source_file(&source.path, source.content)
.await;
for reference in source.references {
let denied = policy == crate::config::usages::DynamicUsages::Deny;
let diagnostic = Diagnostic::warning_or_error(denied || self.lint_strict())
.with_code(globetrotter_model::lint::LintCode::DynamicUsage)
.with_message("translation key is constructed dynamically")
.with_labels(vec![Label::primary(file_id, reference.span).with_message(if denied {
"dynamic usages are denied; inferred prefixes do not count as usages"
} else {
"review this dynamic usage; a specific literal prefix keeps matching keys alive"
})]);
usage_diagnostics.insert(diagnostic, &owner);
}
}
}
}
Ok(())
}
pub async fn lint(
self,
configs: config::Configs<FileId>,
params: &LintParams,
) -> Result<Self, Error> {
tracing::trace!(num_configs = configs.len(), "linting");
let scan_usages = !params.usages.is_empty()
|| configs
.iter()
.any(|config| !config.config.usages.roots.is_empty());
let excluded = if scan_usages {
output_files(&configs)
} else {
BTreeSet::new()
};
let mut tally = Tally::default();
let mut usage_diagnostics = UsageDiagnostics::default();
#[cfg(not(feature = "tree-sitter"))]
if scan_usages {
self.diagnostic_printer.emit(&Diagnostic::note().with_message(
"usage scanning uses text matching because this build lacks the `tree-sitter` feature"
).with_notes(vec!["comments, types, and unrelated templates may count as usages; enable `tree-sitter` for syntax-aware scanning".into()])).await?;
}
#[cfg(feature = "llm-judge")]
let llm_judge = match ¶ms.llm_judge {
Some(params) => Some(crate::llm_judge::judge(params)?),
None => None,
};
#[cfg(not(feature = "llm-judge"))]
if params.llm_judge.is_some() {
tracing::warn!(
"the LLM judge was requested but this build was compiled without the `llm-judge` feature; skipping"
);
}
for config_file in configs {
let config_file = Arc::new(config_file);
let (config_tally, translations) =
self.lint_config(Arc::clone(&config_file), params).await?;
tally += config_tally;
self.collect_usage_diagnostics(
&config_file,
&translations,
params,
&excluded,
&mut usage_diagnostics,
)
.await?;
#[cfg(feature = "llm-judge")]
if let Some(judge) = llm_judge.as_ref() {
self.stream_llm_judge(judge, &translations).await?;
}
}
tally += self.emit_all(&usage_diagnostics.finish()).await?;
if tally.has_issues() {
return Err(FailedWithErrors(tally).into());
}
Ok(self)
}
}
fn effective_usages(
config: &config::ConfigFile<FileId>,
params: &LintParams,
) -> crate::config::usages::UsageConfig {
let mut usages = config.config.usages.clone();
usages.roots = if params.usages.is_empty() {
usages
.roots
.iter()
.map(|path| resolve_path(config.config_dir.as_deref(), path))
.collect()
} else {
params.usages.clone()
};
if let Some(policy) = params.dynamic_usages {
usages.dynamic = policy;
}
if let Some(respect_ignore_files) = params.respect_ignore_files {
usages.respect_ignore_files = respect_ignore_files;
}
if let Some(respect_gitignore) = params.respect_gitignore {
usages.respect_gitignore = respect_gitignore;
}
usages
}
#[derive(Default)]
struct UsageDiagnostics {
findings: BTreeMap<UsageDiagnosticIdentity, OwnedUsageDiagnostic>,
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct UsageDiagnosticIdentity {
file_id: FileId,
start: usize,
end: usize,
code: Option<String>,
severity: Severity,
message: String,
label_message: String,
}
struct OwnedUsageDiagnostic {
diagnostic: Diagnostic<FileId>,
owners: BTreeSet<String>,
}
impl UsageDiagnostics {
fn insert(&mut self, diagnostic: Diagnostic<FileId>, owner: &str) {
let Some(label) = diagnostic.labels.first() else {
return;
};
let identity = UsageDiagnosticIdentity {
file_id: label.file_id,
start: label.range.start,
end: label.range.end,
code: diagnostic.code.clone(),
severity: diagnostic.severity,
message: diagnostic.message.clone(),
label_message: label.message.clone(),
};
self.findings
.entry(identity)
.or_insert_with(|| OwnedUsageDiagnostic {
diagnostic,
owners: BTreeSet::new(),
})
.owners
.insert(owner.to_owned());
}
fn finish(self) -> Vec<Diagnostic<FileId>> {
self.findings
.into_values()
.map(|mut finding| {
finding.diagnostic.notes.push(format!(
"configs: {}",
finding.owners.into_iter().collect::<Vec<_>>().join(", ")
));
finding.diagnostic
})
.collect()
}
}
#[cfg(feature = "rust")]
fn target_identifiers(config: &config::Config, key: &str) -> Vec<String> {
let mut identifiers = Vec::new();
if config.outputs.rust.is_some() {
identifiers.push(crate::rust::key_to_rust_enum_variant(key));
}
identifiers
}
#[cfg(not(feature = "rust"))]
fn target_identifiers(_config: &config::Config, _key: &str) -> Vec<String> {
Vec::new()
}
fn insert_output_file(files: &mut BTreeSet<PathBuf>, base: Option<&Path>, path: &Path) {
if let Ok(canonical) = resolve_path(base, path).canonicalize() {
files.insert(canonical);
}
}
fn output_files(configs: &config::Configs<FileId>) -> BTreeSet<PathBuf> {
let mut dirs = BTreeSet::new();
for config_file in configs {
let base = config_file.config_dir.as_deref();
for output in &config_file.config.outputs.json {
insert_output_file(&mut dirs, base, output.path.as_ref());
}
#[cfg(feature = "typescript")]
if let Some(output) = &config_file.config.outputs.typescript {
for interface in &output.interface_type {
insert_output_file(&mut dirs, base, &interface.path);
}
}
#[cfg(feature = "rust")]
if let Some(output) = &config_file.config.outputs.rust {
for path in &output.output_paths {
insert_output_file(&mut dirs, base, path);
}
}
#[cfg(feature = "golang")]
if let Some(output) = &config_file.config.outputs.golang {
for path in &output.output_paths {
insert_output_file(&mut dirs, base, path);
}
}
#[cfg(feature = "python")]
if let Some(output) = &config_file.config.outputs.python {
for path in &output.output_paths {
insert_output_file(&mut dirs, base, path);
}
}
}
dirs
}
#[must_use]
pub fn resolve_input_files(
configs: &config::Configs<FileId>,
strict: bool,
diagnostics: &mut Vec<Diagnostic<FileId>>,
) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = Vec::new();
for config_file in configs {
let resolved: Vec<PathBuf> = Executor::unique_input_paths(
&config_file.config.inputs,
config_file.config_dir.as_deref(),
strict,
config_file.file_id,
diagnostics,
)
.filter_map(Result::ok)
.map(|(_input, path)| path)
.collect();
paths.extend(resolved);
}
paths.sort();
paths.dedup();
paths
}
#[cfg(test)]
mod tests {
use super::*;
use color_eyre::eyre;
use std::sync::atomic::{AtomicU64, Ordering};
fn temp_dir(prefix: &str) -> eyre::Result<PathBuf> {
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"globetrotter-{prefix}-{}-{nanos}-{unique}",
std::process::id()
));
std::fs::create_dir_all(&path)?;
Ok(path)
}
fn keys(file: &ParsedFile) -> Vec<String> {
file.translations
.0
.keys()
.map(|key| key.as_ref().clone())
.collect()
}
fn source_file(input: config::Input, path: &str, contents: &str) -> SourceFile {
SourceFile {
input,
path: PathBuf::from(path),
file_id: 0,
contents: contents.to_string(),
}
}
#[test_util::test]
fn prepend_filename_prefixes_with_file_stem() {
let input = config::Input::new("translations/a.toml").with_prepend_filename(true);
let raw_translations = indoc::indoc! {r#"
[section]
en = "Hello"
"#};
let file = source_file(input, "/base/dialogs/delete-user.toml", raw_translations);
let parsed = parse_source_file(&file, None, true);
assert_eq!(keys(&parsed), vec!["delete-user.section".to_string()]);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
}
#[test_util::test]
fn prepend_relative_path_prefixes_with_full_path_segments() {
let input =
config::Input::new("translations/airtype/**/*.toml").with_prepend_relative_path(true);
let base_dir = PathBuf::from("/workspace/translations/airtype");
let path = base_dir.join("dialogs/chat/too-many-files.toml");
let raw_translations = indoc::indoc! {r#"
[section]
en = "Hello"
"#};
let file = source_file(input, &path.to_string_lossy(), raw_translations);
let parsed = parse_source_file(&file, Some(&base_dir), true);
assert_eq!(
keys(&parsed),
vec!["dialogs.chat.too-many-files.section".to_string()]
);
}
#[test_util::test]
fn prepend_relative_path_disabled_preserves_existing_behavior() {
let input = config::Input::new("translations/upload.toml").with_prefix("upload");
let raw_translations = indoc::indoc! {r#"
[message]
en = "Hello"
"#};
let file = source_file(input, "/base/upload.toml", raw_translations);
let parsed = parse_source_file(&file, None, true);
assert_eq!(keys(&parsed), vec!["upload.message".to_string()]);
}
#[test_util::test]
fn parse_errors_become_diagnostics() {
let input = config::Input::new("translations/broken.toml");
let file = source_file(input, "/base/broken.toml", "[unterminated");
let parsed = parse_source_file(&file, None, true);
assert!(parsed.translations.is_empty());
assert!(parsed.diagnostics.iter().any(DiagnosticExt::is_error));
}
#[test_util::test]
fn duplicate_keys_label_only_their_own_definitions() {
let parse = |file_id: FileId, raw: &str| {
let mut diagnostics = vec![];
let translations = model::Translations::from_str(raw, file_id, true, &mut diagnostics)?;
Ok::<_, model::toml::Error>(ParsedFile {
file_id,
translations,
diagnostics,
})
};
let first = parse(
1,
indoc::indoc! {r#"
[shared]
en = "one"
[only-here]
en = "unique"
"#},
)?;
let second = parse(
2,
indoc::indoc! {r#"
[shared]
en = "two"
"#},
)?;
let mut diagnostics = vec![];
let combined = combine_translations(vec![first, second], &mut diagnostics);
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(diagnostics[0].message, "duplicate key `shared`");
let labelled_files: Vec<FileId> = diagnostics[0]
.labels
.iter()
.map(|label| label.file_id)
.collect();
assert_eq!(labelled_files, vec![1, 2]);
assert_eq!(combined.len(), 2);
}
#[test_util::test]
fn unique_input_paths_respects_exclude_patterns() -> eyre::Result<()> {
let dir = temp_dir("exclude-patterns")?;
let keep = dir.join("keep.toml");
let skip = dir.join("skip.toml");
std::fs::write(
&keep,
indoc::indoc! {r#"
[a]
en = "Hello"
"#},
)?;
std::fs::write(
&skip,
indoc::indoc! {r#"
[b]
en = "Bye"
"#},
)?;
let input = config::Input::new(dir.join("*.toml").to_string_lossy().into_owned())
.with_exclude([
skip.to_string_lossy().into_owned(),
dir.join("absent-*.toml").to_string_lossy().into_owned(),
]);
let mut diagnostics = Vec::new();
let mut resolved: Vec<PathBuf> =
Executor::unique_input_paths(&[input], None, true, None, &mut diagnostics)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(|(_input, path)| path)
.collect();
resolved.sort();
assert!(diagnostics.is_empty(), "{diagnostics:?}");
assert_eq!(resolved, vec![keep]);
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[test_util::test]
fn invalid_exclude_pattern_is_an_error() -> eyre::Result<()> {
let dir = temp_dir("invalid-exclude")?;
std::fs::write(dir.join("a.toml"), "")?;
let input = config::Input::new(dir.join("*.toml").to_string_lossy().into_owned())
.with_exclude([dir.join("[unclosed").to_string_lossy().into_owned()]);
let mut diagnostics = Vec::new();
let results: Vec<_> =
Executor::unique_input_paths(&[input], None, true, None, &mut diagnostics).collect();
assert!(
results
.iter()
.any(|result| matches!(result, Err(Error::Pattern { .. }))),
"{results:?}"
);
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[test_util::test]
fn output_files_include_all_generated_files() -> eyre::Result<()> {
let base = temp_dir("output-dirs")?;
let json_dir = base.join("generated/json");
std::fs::create_dir_all(&json_dir)?;
std::fs::write(json_dir.join("en.json"), "{}")?;
#[cfg(feature = "typescript")]
let ts_dir = {
let dir = base.join("generated/ts");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("translations.d.ts"), "")?;
dir
};
#[cfg(feature = "rust")]
let rust_dir = {
let dir = base.join("generated/rust");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("translations.rs"), "")?;
dir
};
#[cfg(feature = "golang")]
let go_dir = {
let dir = base.join("generated/go");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("translations.go"), "")?;
dir
};
#[cfg(feature = "python")]
let py_dir = {
let dir = base.join("generated/python");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("translations.py"), "")?;
dir
};
let outputs = config::Outputs::new()
.with_json([config::JsonOutputConfig::new("generated/json/en.json")]);
#[cfg(feature = "typescript")]
let outputs = outputs.with_typescript(globetrotter_typescript::OutputConfig {
interface_type: vec![globetrotter_typescript::config::InterfaceTypeOutputConfig {
path: PathBuf::from("generated/ts/translations.d.ts"),
}],
});
#[cfg(feature = "rust")]
let outputs = outputs.with_rust(globetrotter_rust::OutputConfig::new([PathBuf::from(
"generated/rust/translations.rs",
)]));
#[cfg(feature = "golang")]
let outputs = outputs.with_golang(globetrotter_golang::OutputConfig {
output_paths: vec![PathBuf::from("generated/go/translations.go")],
});
#[cfg(feature = "python")]
let outputs = outputs.with_python(globetrotter_python::OutputConfig {
output_paths: vec![PathBuf::from("generated/python/translations.py")],
});
let configs = vec![config::ConfigFile {
file_id: None,
config_dir: Some(base.clone()),
config: config::Config::new("demo")
.with_input(config::Input::new("translations/*.toml"))
.with_outputs(outputs),
}];
let dirs = output_files(&configs);
assert!(dirs.contains(&json_dir.join("en.json").canonicalize()?));
#[cfg(feature = "typescript")]
assert!(dirs.contains(&ts_dir.join("translations.d.ts").canonicalize()?));
#[cfg(feature = "rust")]
assert!(dirs.contains(&rust_dir.join("translations.rs").canonicalize()?));
#[cfg(feature = "rust")]
assert!(!dirs.contains(&rust_dir.canonicalize()?));
#[cfg(feature = "golang")]
assert!(dirs.contains(&go_dir.join("translations.go").canonicalize()?));
#[cfg(feature = "python")]
assert!(dirs.contains(&py_dir.join("translations.py").canonicalize()?));
std::fs::remove_dir_all(base)?;
Ok(())
}
}