use crate::{CliError, CliResult, FixArgs};
use linguini_analyzer::{locale_public_messages, schema_public_messages};
use linguini_config::LinguiniConfig;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use super::io::{create_dir_all, path_for_output, read_file, read_project_config, write_file};
use super::sources::{
expected_locale_path, load_locale_sources, load_schema_sources, locale_index,
};
use super::util::pluralize;
#[derive(Debug, Clone)]
struct ProjectFix {
id: String,
title: String,
operation: FixOperation,
}
#[derive(Debug, Clone)]
enum FixOperation {
CreateFile { path: PathBuf, contents: String },
AppendFile { path: PathBuf, contents: String },
}
pub(crate) fn fix_project(root: &Path, args: &FixArgs) -> CliResult<String> {
let config = read_project_config(root)?;
let fixes = available_project_fixes(root, &config)?;
if !args.all && args.ids.is_empty() {
return Ok(render_fix_list(&fixes));
}
let selected = select_fixes(&fixes, args)?;
let mut output = String::new();
for fix in selected {
apply_project_fix(root, &fix, &mut output)?;
}
if output.is_empty() {
output.push_str("no automatic fixes applied\n");
}
Ok(output)
}
fn render_fix_list(fixes: &[ProjectFix]) -> String {
let mut output = String::new();
if fixes.is_empty() {
output.push_str("no automatic fixes available\n");
return output;
}
output.push_str("available fixes:\n");
for fix in fixes {
output.push_str(&format!("- {}: {}\n", fix.id, fix.title));
}
output.push_str("run `linguini fix --all` or `linguini fix <id>...`\n");
output
}
fn available_project_fixes(root: &Path, config: &LinguiniConfig) -> CliResult<Vec<ProjectFix>> {
let schema_files = load_schema_sources(root, config)?;
let locale_files = load_locale_sources(root, config)?;
let locale_index = locale_index(&locale_files);
let mut fixes = Vec::new();
for schema_file in &schema_files {
let schema_messages = schema_public_messages(&schema_file.ast);
let schema_message_names = schema_messages
.iter()
.map(|message| message.name.clone())
.collect::<Vec<_>>();
for locale in &config.project.locales {
let key = (schema_file.file.namespace.clone(), locale.clone());
match locale_index.get(&key) {
Some(locale_file) => {
let locale_messages = locale_public_messages(&locale_file.ast);
let missing = missing_schema_message_names(&schema_messages, &locale_messages);
if missing.is_empty() {
continue;
}
fixes.push(ProjectFix {
id: missing_messages_fix_id(&schema_file.file.namespace, locale),
title: format!(
"add {} missing message {} to {}",
missing.len(),
pluralize(missing.len(), "stub", "stubs"),
path_for_output(root, &locale_file.file.path)
),
operation: FixOperation::AppendFile {
path: locale_file.file.path.clone(),
contents: append_stub_text(&locale_file.source, &missing),
},
});
}
None => {
let path =
expected_locale_path(root, config, &schema_file.file.namespace, locale);
fixes.push(ProjectFix {
id: missing_locale_fix_id(&schema_file.file.namespace, locale),
title: format!("create locale file {}", path_for_output(root, &path)),
operation: FixOperation::CreateFile {
path,
contents: locale_stub_text(&schema_message_names),
},
});
}
}
}
}
Ok(fixes)
}
fn select_fixes(fixes: &[ProjectFix], args: &FixArgs) -> CliResult<Vec<ProjectFix>> {
if args.all {
return Ok(fixes.to_vec());
}
let by_id = fixes
.iter()
.map(|fix| (fix.id.as_str(), fix))
.collect::<BTreeMap<_, _>>();
let mut selected = Vec::new();
let mut missing = Vec::new();
for id in &args.ids {
match by_id.get(id.as_str()) {
Some(fix) => selected.push((*fix).clone()),
None => missing.push(id.clone()),
}
}
if !missing.is_empty() {
return Err(CliError::Diagnostics(format!(
"unknown fix {}: {}\nrun `linguini fix` to list available fixes\n",
pluralize(missing.len(), "id", "ids"),
missing.join(", ")
)));
}
Ok(selected)
}
fn apply_project_fix(root: &Path, fix: &ProjectFix, output: &mut String) -> CliResult<()> {
match &fix.operation {
FixOperation::CreateFile { path, contents } => {
if let Some(parent) = path.parent() {
create_dir_all(parent)?;
}
if !path.exists() {
write_file(path, contents)?;
}
output.push_str(&format!(
"applied {}: created {}\n",
fix.id,
path_for_output(root, path)
));
}
FixOperation::AppendFile { path, contents } => {
let mut existing = read_file(path)?;
existing.push_str(contents);
write_file(path, &existing)?;
output.push_str(&format!(
"applied {}: updated {}\n",
fix.id,
path_for_output(root, path)
));
}
}
Ok(())
}
fn missing_schema_message_names(
schema_messages: &[linguini_analyzer::RequiredLocaleMessage],
locale_messages: &[linguini_analyzer::ImplementedLocaleMessage],
) -> Vec<String> {
let locale_names = locale_messages
.iter()
.map(|message| message.name.as_str())
.collect::<BTreeSet<_>>();
schema_messages
.iter()
.filter(|message| !locale_names.contains(message.name.as_str()))
.map(|message| message.name.clone())
.collect()
}
fn append_stub_text(existing: &str, names: &[String]) -> String {
let mut output = String::new();
if !existing.is_empty() && !existing.ends_with('\n') {
output.push('\n');
}
if !existing.is_empty() {
output.push('\n');
}
output.push_str(&locale_stub_text(names));
output
}
fn locale_stub_text(names: &[String]) -> String {
let mut output = String::new();
let mut groups: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
let mut top_level = Vec::new();
for name in names {
if let Some((group, message)) = name.split_once('.') {
groups.entry(group).or_default().push(message);
} else {
top_level.push(name.as_str());
}
}
for name in top_level {
output.push_str(&format!("{name} = TODO\n"));
}
for (group, messages) in groups {
output.push_str(&format!("{group} {{\n"));
for message in messages {
output.push_str(&format!(" {message} = TODO\n"));
}
output.push_str("}\n");
}
output
}
pub(crate) fn missing_locale_fix_id(namespace: &str, locale: &str) -> String {
format!("missing-locale:{}:{}", fix_id_namespace(namespace), locale)
}
pub(crate) fn missing_messages_fix_id(namespace: &str, locale: &str) -> String {
format!(
"missing-messages:{}:{}",
fix_id_namespace(namespace),
locale
)
}
fn fix_id_namespace(namespace: &str) -> String {
if namespace.is_empty() {
"root".to_owned()
} else {
namespace.to_owned()
}
}