use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use globset::{Glob, GlobSetBuilder};
use ignore::WalkBuilder;
use polib::catalog::Catalog;
use polib::po_file;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PoKey {
pub msgctxt: Option<String>,
pub msgid: String,
pub msgid_plural: Option<String>,
}
#[derive(Debug, Default)]
pub struct PoIndex {
keys: HashSet<PoKey>,
}
impl PoIndex {
pub fn from_keys(keys: impl IntoIterator<Item = PoKey>) -> Self {
Self {
keys: keys.into_iter().collect(),
}
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn keys(&self) -> impl Iterator<Item = &PoKey> {
self.keys.iter()
}
pub fn contains_msgid(&self, msgid: &str) -> bool {
self.keys
.iter()
.any(|k| k.msgid == msgid || k.msgid_plural.as_deref() == Some(msgid))
}
}
pub fn collect_po_files(
roots: &[PathBuf],
po_patterns: &[String],
ignore_dirs: &[String],
) -> Result<Vec<PathBuf>> {
let mut builder = GlobSetBuilder::new();
for pattern in po_patterns {
builder.add(
Glob::new(pattern).with_context(|| format!("invalid po_pattern glob: {pattern}"))?,
);
}
let globs = builder.build().context("failed to build PO glob set")?;
let ignored: HashSet<String> = ignore_dirs.iter().cloned().collect();
let mut found = Vec::new();
let mut seen = HashSet::new();
for root in roots {
let ignored = ignored.clone();
let mut walker = WalkBuilder::new(root);
walker.filter_entry(move |entry| {
if entry.file_type().is_some_and(|t| t.is_dir())
&& let Some(name) = entry.file_name().to_str()
{
return !ignored.contains(name);
}
true
});
for result in walker.build() {
let entry = result.with_context(|| format!("error walking {}", root.display()))?;
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(path);
if globs.is_match(rel) || globs.is_match(path) {
let owned = path.to_path_buf();
if seen.insert(owned.clone()) {
found.push(owned);
}
}
}
}
found.sort();
Ok(found)
}
pub fn build_index(files: &[&Path]) -> Result<PoIndex> {
let mut keys = HashSet::new();
for file in files {
let catalog = parse_catalog(file)?;
for message in catalog.messages() {
if message.flags().is_fuzzy() {
continue;
}
let msgid = message.msgid();
if msgid.is_empty() {
continue;
}
let msgid_plural = if message.is_plural() {
message.msgid_plural().ok().map(|s| s.to_string())
} else {
None
};
let msgctxt = message.msgctxt().map(|s| s.to_string());
keys.insert(PoKey {
msgctxt,
msgid: msgid.to_string(),
msgid_plural,
});
}
}
Ok(PoIndex { keys })
}
pub fn load_index(
roots: &[PathBuf],
po_patterns: &[String],
ignore_dirs: &[String],
) -> Result<PoIndex> {
let files = collect_po_files(roots, po_patterns, ignore_dirs)?;
if files.is_empty() {
return Err(anyhow!(
"no PO files matched {:?} under roots {:?}",
po_patterns,
roots
));
}
let refs: Vec<&Path> = files.iter().map(|p| p.as_path()).collect();
build_index(&refs)
}
fn parse_catalog(file: &Path) -> Result<Catalog> {
let path = file.to_path_buf();
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| po_file::parse(&path)));
match outcome {
Ok(Ok(catalog)) => Ok(catalog),
Ok(Err(e)) => Err(anyhow!("failed to parse PO file {}: {}", file.display(), e)),
Err(_) => Err(anyhow!(
"failed to parse PO file {} (malformed content)",
file.display()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, body).unwrap();
path
}
const HEADER: &str = "msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n\n";
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("dead-poets-po-{tag}"));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn union_across_three_locales() {
let dir = temp_dir("union");
write(
&dir,
"fr/LC_MESSAGES/messages.po",
&format!("{HEADER}msgid \"shared\"\nmsgstr \"partage\"\n"),
);
write(
&dir,
"de/LC_MESSAGES/messages.po",
&format!(
"{HEADER}msgid \"shared\"\nmsgstr \"geteilt\"\n\nmsgid \"only_de\"\nmsgstr \"nur\"\n"
),
);
write(
&dir,
"es/LC_MESSAGES/messages.po",
&format!(
"{HEADER}msgid \"1 apple\"\nmsgid_plural \"%d apples\"\nmsgstr[0] \"manzana\"\nmsgstr[1] \"manzanas\"\n"
),
);
let files =
collect_po_files(std::slice::from_ref(&dir), &["**/*.po".to_string()], &[]).unwrap();
assert_eq!(files.len(), 3, "all three locale files collected");
let refs: Vec<&Path> = files.iter().map(|p| p.as_path()).collect();
let index = build_index(&refs).unwrap();
assert!(index.contains_msgid("shared"));
assert!(index.contains_msgid("only_de"));
let plural = index
.keys()
.find(|k| k.msgid == "1 apple")
.expect("plural key present");
assert_eq!(plural.msgid_plural.as_deref(), Some("%d apples"));
assert_eq!(index.len(), 3);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn skips_obsolete_and_fuzzy() {
let dir = temp_dir("skip");
let body = format!(
"{HEADER}\
msgid \"active\"\nmsgstr \"a\"\n\n\
#, fuzzy\nmsgid \"fuzzy_key\"\nmsgstr \"f\"\n\n\
#~ msgid \"obsolete_key\"\n#~ msgstr \"o\"\n"
);
let file = write(&dir, "messages.po", &body);
let index = build_index(&[file.as_path()]).unwrap();
assert!(index.contains_msgid("active"));
assert!(!index.contains_msgid("fuzzy_key"), "fuzzy excluded");
assert!(!index.contains_msgid("obsolete_key"), "obsolete excluded");
assert_eq!(index.len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn msgids_are_decoded() {
let dir = temp_dir("decode");
let file = write(
&dir,
"messages.po",
&format!("{HEADER}msgid \"a\\nb\"\nmsgstr \"x\"\n"),
);
let index = build_index(&[file.as_path()]).unwrap();
let key = index.keys().next().unwrap();
assert_eq!(key.msgid, "a\nb", "escape sequence decoded to real newline");
assert!(key.msgid.contains('\n'));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn broken_po_errors_with_path() {
let dir = temp_dir("broken");
let file = write(&dir, "broken.po", "msgid \"orphan\"\nmsgstr \"x\"\n");
let err = build_index(&[file.as_path()]).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("broken.po"), "error names the file: {msg}");
std::fs::remove_dir_all(&dir).ok();
}
}