///|
pub(all) struct POEntry {
context : String?
id : String
plural : String?
translations : Array[String]
comments : Array[String]
flags : Array[String]
obsolete : Bool
} derive(Eq, Debug)
///|
pub(all) struct POCatalog {
entries : Array[POEntry]
trailing_comments : Array[String]
} derive(Eq, Debug)
///|
pub extend POEntry with Eq::{equal, not_equal}
///|
pub extend POEntry with @debug.Debug::{to_repr}
///|
pub extend POCatalog with Eq::{equal, not_equal}
///|
pub extend POCatalog with @debug.Debug::{to_repr}
///|
fn po_unquote(text : String) -> String raise {
let chars = text.trim().to_owned().to_array()
if chars.length() < 2 || chars[0] != '"' || chars[chars.length() - 1] != '"' {
raise InvalidDocument("Expected a quoted PO string")
}
let output = StringBuilder()
let mut i = 1
while i < chars.length() - 1 {
let c = chars[i]
i += 1
if c == '"' || c.to_int() < 32 {
raise InvalidDocument("Unescaped control or quote in PO string")
}
if c != '\\' {
output.write_char(c)
continue
}
if i >= chars.length() - 1 {
raise InvalidDocument("Incomplete PO escape")
}
let escape = chars[i]
i += 1
let value = match escape {
'n' => '\n'
'r' => '\r'
't' => '\t'
'a' => '\u0007'
'b' => '\u0008'
'f' => '\u000c'
'v' => '\u000b'
'\\' => '\\'
'"' => '"'
'0'..<='7' => {
let mut n = escape.to_int() - 48
let mut count = 1
while count < 3 &&
i < chars.length() - 1 &&
chars[i] >= '0' &&
chars[i] <= '7' {
n = n * 8 + chars[i].to_int() - 48
i += 1
count += 1
}
if n == 0 || n > 127 {
raise InvalidDocument(
"PO octal escapes must encode non-NUL ASCII; use Unicode text for other characters",
)
}
n.to_char().unwrap()
}
_ => raise InvalidDocument("Unsupported PO escape")
}
output.write_char(value)
}
output.to_string()
}
///|
fn po_quote(text : String) -> String raise {
if text.contains("\u0000") {
raise InvalidDocument("NUL in PO string")
}
let output = StringBuilder()
output.write_char('"')
for c in text {
match c {
'"' => output.write_string("\\\"")
'\\' => output.write_string("\\\\")
'\n' => output.write_string("\\n")
'\r' => output.write_string("\\r")
'\t' => output.write_string("\\t")
_ =>
if c.to_int() < 32 || c.to_int() == 127 {
let n = c.to_int()
output.write_char('\\')
output.write_char((48 + n / 64).to_char().unwrap())
output.write_char((48 + n / 8 % 8).to_char().unwrap())
output.write_char((48 + n % 8).to_char().unwrap())
} else {
output.write_char(c)
}
}
}
output.write_char('"')
output.to_string()
}
///|
priv struct POBuilder {
fields : Map[String, String]
comments : Array[String]
flags : Array[String]
mut active : String?
mut obsolete : Bool?
}
///|
fn po_builder() -> POBuilder {
{ fields: Map([]), comments: [], flags: [], active: None, obsolete: None, }
}
///|
fn POBuilder::finish(self : POBuilder) -> POEntry raise {
guard self.fields.get("msgid") is Some(id) else {
raise InvalidDocument("PO entry has no msgid")
}
let plural = self.fields.get("msgid_plural")
let translations : Array[String] = []
if plural is None {
guard self.fields.get("msgstr") is Some(text) &&
!self.fields.contains("msgstr[0]") else {
raise InvalidDocument("PO singular entry needs one msgstr")
}
translations.push(text)
} else {
if self.fields.contains("msgstr") {
raise InvalidDocument("PO plural entry requires indexed msgstr")
}
let mut i = 0
while self.fields.get("msgstr[" + i.to_string() + "]") is Some(text) {
translations.push(text)
i += 1
}
if translations.is_empty() ||
self.fields.length() !=
translations.length() +
2 +
(if self.fields.contains("msgctxt") { 1 } else { 0 }) {
raise InvalidDocument("PO plural indices must be contiguous from zero")
}
}
{
context: self.fields.get("msgctxt"),
id,
plural,
translations,
comments: self.comments.copy(),
flags: self.flags.copy(),
obsolete: self.obsolete.unwrap_or(false),
}
}
///|
pub fn parse_po(text : String) -> POCatalog raise {
if text.char_length() > 8388608 || text.contains("\u0000") {
raise InvalidDocument("PO input exceeds budget or contains NUL")
}
let entries : Array[POEntry] = []
let mut builder = po_builder()
for raw in text.split("\n") {
let line = raw.trim().to_owned()
if line.is_empty() {
if !builder.fields.is_empty() {
entries.push(builder.finish())
builder = po_builder()
}
continue
}
let obsolete = line.has_prefix("#~")
let line = if obsolete { line[2:].trim_start().to_owned() } else { line }
if line.has_prefix("#") {
if builder.active.map(key => key.has_prefix("msgstr")) == Some(true) {
entries.push(builder.finish())
builder = po_builder()
}
if line.has_prefix("#,") {
for raw_flag in line[2:].split(",") {
let flag = raw_flag.trim().to_owned()
if !flag.is_empty() && !builder.flags.contains(flag) {
builder.flags.push(flag)
}
}
} else {
builder.comments.push(line)
}
continue
}
if line.has_prefix("\"") {
guard builder.active is Some(key) && builder.obsolete == Some(obsolete) else {
raise InvalidDocument("PO continuation has no matching field")
}
builder.fields[key] = builder.fields[key] + po_unquote(line)
continue
}
let mut recognized = false
for prefix in ["msgctxt", "msgid_plural", "msgid", "msgstr"] {
if !line.has_prefix(prefix) {
continue
}
let mut key = prefix
let mut offset = prefix.length()
if prefix == "msgstr" && line[offset:].has_prefix("[") {
guard line.find("]") is Some(end) else {
raise InvalidDocument("Unclosed PO plural index")
}
let n = @strconv.parse_int(line[offset + 1:end])
if n < 0 || n > 1023 || line[offset + 1:end].to_owned() != n.to_string() {
raise InvalidDocument("Invalid PO plural index")
}
key = "msgstr[" + n.to_string() + "]"
offset = end + 1
}
if offset >= line.length() || (line[offset] != 32 && line[offset] != 9) {
continue
}
if (prefix == "msgctxt" || prefix == "msgid") &&
builder.active.map(key => key.has_prefix("msgstr")) == Some(true) {
entries.push(builder.finish())
builder = po_builder()
}
if builder.fields.contains(key) ||
(builder.obsolete is Some(previous) && previous != obsolete) {
raise InvalidDocument("Duplicate or mixed obsolete PO field")
}
builder.fields[key] = po_unquote(line[offset:].to_owned())
builder.active = Some(key)
builder.obsolete = Some(obsolete)
recognized = true
break
}
if !recognized {
raise InvalidDocument("Unsupported PO directive")
}
if entries.length() > 100000 {
raise InvalidDocument("PO entry budget exceeded")
}
}
if !builder.fields.is_empty() {
entries.push(builder.finish())
builder = po_builder()
}
let result = { entries, trailing_comments: builder.comments.copy(), }
po_validate(result)
result
}
///|
fn po_validate(catalog : POCatalog) -> Unit raise {
if catalog.entries.length() > 100000 {
raise InvalidDocument("PO entry budget exceeded")
}
let seen : Map[String, Bool] = Map([])
let mut total = 0
for entry in catalog.entries {
let texts = [
entry.id,
entry.context.unwrap_or(""),
entry.plural.unwrap_or(""),
] +
entry.translations +
entry.comments +
entry.flags
for text in texts {
let size = text.char_length()
if size > 8388608 - total || text.contains("\u0000") {
raise InvalidDocument("PO model exceeds budget or contains NUL")
}
total += size
}
let key = Json::array([
match entry.context {
Some(s) => Json::string(s)
None => Json::null()
},
Json::string(entry.id),
]).stringify()
if !entry.obsolete && seen.contains(key) {
raise InvalidDocument("Duplicate PO msgctxt/msgid")
}
if !entry.obsolete {
seen[key] = true
}
if entry.translations.is_empty() ||
(entry.plural is None && entry.translations.length() != 1) ||
entry.translations.length() > 1024 {
raise InvalidDocument("Invalid PO translations")
}
if entry.comments.any(s => {
!s.has_prefix("#") ||
s.has_prefix("#,") ||
s.contains("\n") ||
s.contains("\r")
}) ||
entry.flags.any(s => {
s.is_empty() || s.contains(",") || s.contains("\n") || s.contains("\r")
}) {
raise InvalidDocument("Invalid PO comment or flag")
}
}
for text in catalog.trailing_comments {
let size = text.char_length()
if size > 8388608 - total || text.contains("\u0000") {
raise InvalidDocument("PO model exceeds budget or contains NUL")
}
total += size
}
if catalog.trailing_comments.any(s => {
!s.has_prefix("#") || s.contains("\n") || s.contains("\r")
}) {
raise InvalidDocument("Invalid trailing PO comment")
}
}
///|
pub fn POCatalog::stringify(self : POCatalog) -> String raise {
po_validate(self)
let output = StringBuilder()
for entry in self.entries {
let prefix = if entry.obsolete { "#~ " } else { "" }
for comment in entry.comments {
output.write_string(comment + "\n")
}
if !entry.flags.is_empty() {
output.write_string("#, " + entry.flags.join(", ") + "\n")
}
if entry.context is Some(context) {
output.write_string(prefix + "msgctxt " + po_quote(context) + "\n")
}
output.write_string(prefix + "msgid " + po_quote(entry.id) + "\n")
if entry.plural is Some(plural) {
output.write_string(prefix + "msgid_plural " + po_quote(plural) + "\n")
for i in 0.. 8388608 {
raise InvalidDocument("PO output exceeds budget")
}
text
}