use std::{collections::HashSet, fmt::Write as _};
pub struct PotEntry {
pub(crate) msgid: String,
pub(crate) msgid_plural: Option<String>,
pub(crate) reference: Option<String>,
pub(crate) comment: Option<String>,
}
pub fn pot_entry_block(entry: &PotEntry) -> String {
let comment = entry.comment.as_ref().map_or_else(String::new, |c| {
c.lines().fold(String::new(), |mut out, line| {
let _ = writeln!(out, "#. {line}");
out
})
});
let reference = entry.reference.as_ref().map_or_else(String::new, |r| format!("#: {r}\n"));
let msgid = pot_escape(&entry.msgid);
entry.msgid_plural.as_ref().map_or_else(
|| format!("\n{comment}{reference}msgid \"{msgid}\"\nmsgstr \"\"\n"),
|plural| {
let plural = pot_escape(plural);
format!(
"\n{comment}{reference}msgid \"{msgid}\"\nmsgid_plural \"{plural}\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n"
)
},
)
}
pub fn collect_pot_msgids(content: &str) -> HashSet<String> {
collect_pot_entries_ordered(content).into_iter().map(|e| e.msgid).collect()
}
pub fn collect_pot_entries_ordered(content: &str) -> Vec<PotEntry> {
let mut entries: Vec<PotEntry> = Vec::new();
let mut current_id = String::new();
let mut current_plural: Option<String> = None;
let mut current_reference: Option<String> = None;
let mut current_comment: Option<String> = None;
let mut pending_reference: Option<String> = None;
let mut pending_comment: Option<String> = None;
let mut in_msgid = false;
let mut in_msgid_plural = false;
let flush = |current_id: &mut String,
current_plural: &mut Option<String>,
current_reference: &mut Option<String>,
current_comment: &mut Option<String>,
entries: &mut Vec<PotEntry>| {
if !current_id.is_empty() || current_plural.is_some() {
entries.push(PotEntry {
msgid: std::mem::take(current_id),
msgid_plural: current_plural.take(),
reference: current_reference.take(),
comment: current_comment.take(),
});
} else {
*current_reference = None;
*current_comment = None;
}
};
for line in content.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("#.") {
let text = rest.trim();
pending_comment =
Some(pending_comment.take().map_or_else(|| text.to_string(), |existing| format!("{existing}\n{text}")));
} else if let Some(rest) = line.strip_prefix("#:") {
pending_reference = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix("msgid_plural ") {
current_plural = Some(po_unescape(rest));
in_msgid = false;
in_msgid_plural = true;
} else if let Some(rest) = line.strip_prefix("msgid ") {
flush(&mut current_id, &mut current_plural, &mut current_reference, &mut current_comment, &mut entries);
current_id = po_unescape(rest);
current_reference = pending_reference.take();
current_comment = pending_comment.take();
in_msgid = true;
in_msgid_plural = false;
} else if line.starts_with("msgstr") {
flush(&mut current_id, &mut current_plural, &mut current_reference, &mut current_comment, &mut entries);
in_msgid = false;
in_msgid_plural = false;
} else if line.starts_with('"') {
if in_msgid_plural {
if let Some(p) = current_plural.as_mut() {
p.push_str(&po_unescape(line));
}
} else if in_msgid {
current_id.push_str(&po_unescape(line));
}
}
}
flush(&mut current_id, &mut current_plural, &mut current_reference, &mut current_comment, &mut entries);
entries
}
pub fn pot_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out
}
pub fn po_unescape(s: &str) -> String {
let s = s.trim();
if s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') {
return String::new();
}
let inner = &s[1..s.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('"') => out.push('"'),
Some(c) => {
out.push('\\');
if c != '\\' {
out.push(c);
}
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pot_escape_roundtrip() {
let s = "say \"hi\" and \\bye\nnewline";
let escaped = pot_escape(s);
assert_eq!(escaped, r#"say \"hi\" and \\bye\nnewline"#);
assert_eq!(po_unescape(&format!("\"{escaped}\"")), s);
}
#[test]
fn collect_msgids_finds_existing() {
let pot = "msgid \"\"\nmsgstr \"\"\n\nmsgid \"Cancel\"\nmsgstr \"\"\n\nmsgid \"OK\"\nmsgstr \"OK\"\n";
let ids = collect_pot_msgids(pot);
assert!(ids.contains("Cancel"));
assert!(ids.contains("OK"));
}
#[test]
fn collect_pot_entries_captures_plural_shape() {
let pot = "msgid \"\"\nmsgstr \"\"\n\nmsgid \"{} result\"\nmsgid_plural \"{} results\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"Cancel\"\nmsgstr \"\"\n";
let entries = collect_pot_entries_ordered(pot);
let plural_entry = entries.iter().find(|e| e.msgid == "{} result").unwrap();
assert_eq!(plural_entry.msgid_plural.as_deref(), Some("{} results"));
let singular_entry = entries.iter().find(|e| e.msgid == "Cancel").unwrap();
assert_eq!(singular_entry.msgid_plural, None);
}
#[test]
fn collect_pot_entries_attaches_a_reference_to_the_entry_below_it() {
let pot = "msgid \"\"\nmsgstr \"\"\n\nmsgid \"From Rust\"\nmsgstr \"\"\n\n#: swift\nmsgid \"From Swift\"\nmsgstr \"\"\n";
let entries = collect_pot_entries_ordered(pot);
let rust = entries.iter().find(|e| e.msgid == "From Rust").unwrap();
assert_eq!(rust.reference, None, "the reference belongs to the entry after it, not before");
let swift = entries.iter().find(|e| e.msgid == "From Swift").unwrap();
assert_eq!(swift.reference.as_deref(), Some("swift"));
}
#[test]
fn a_reference_above_the_header_does_not_leak_onto_the_first_entry() {
let pot = "#: swift\nmsgid \"\"\nmsgstr \"\"\n\nmsgid \"Cancel\"\nmsgstr \"\"\n";
let entries = collect_pot_entries_ordered(pot);
let cancel = entries.iter().find(|e| e.msgid == "Cancel").unwrap();
assert_eq!(cancel.reference, None);
}
#[test]
fn pot_entry_block_round_trips_a_reference() {
let entry = PotEntry {
msgid: "Continue".to_string(),
msgid_plural: None,
reference: Some("kt".to_string()),
comment: None,
};
let rendered = pot_entry_block(&entry);
assert!(rendered.contains("#: kt\nmsgid \"Continue\""));
let parsed = collect_pot_entries_ordered(&rendered);
assert_eq!(parsed[0].reference.as_deref(), Some("kt"));
}
#[test]
fn pot_entry_block_round_trips_a_comment() {
let entry = PotEntry {
msgid: "Continue".to_string(),
msgid_plural: None,
reference: Some("kt".to_string()),
comment: Some("TRANSLATORS: on the last onboarding page".to_string()),
};
let rendered = pot_entry_block(&entry);
assert!(rendered.contains("#. TRANSLATORS: on the last onboarding page\n#: kt\nmsgid"));
let parsed = collect_pot_entries_ordered(&rendered);
assert_eq!(parsed[0].comment.as_deref(), Some("TRANSLATORS: on the last onboarding page"));
}
#[test]
fn a_comment_spanning_several_lines_round_trips() {
let entry = PotEntry {
msgid: "Continue".to_string(),
msgid_plural: None,
reference: None,
comment: Some("TRANSLATORS: first half\nsecond half".to_string()),
};
let rendered = pot_entry_block(&entry);
assert!(rendered.contains("#. TRANSLATORS: first half\n#. second half\n"));
let parsed = collect_pot_entries_ordered(&rendered);
assert_eq!(parsed[0].comment.as_deref(), Some("TRANSLATORS: first half\nsecond half"));
}
#[test]
fn a_comment_above_the_header_does_not_leak_onto_the_first_entry() {
let pot = "#. TRANSLATORS: stray\nmsgid \"\"\nmsgstr \"\"\n\nmsgid \"Cancel\"\nmsgstr \"\"\n";
let entries = collect_pot_entries_ordered(pot);
let cancel = entries.iter().find(|e| e.msgid == "Cancel").unwrap();
assert_eq!(cancel.comment, None);
}
}