///|
pub(all) struct LocalizationToken {
name : String
text : String
} derive(Eq, Debug)
///|
pub(all) struct Localization {
language : String
tokens : Array[LocalizationToken]
encoding : String
} derive(Eq, Debug)
///|
pub(all) struct LocalizationIssue {
token : String
code : String
message : String
} derive(Eq, Debug)
///|
pub extend LocalizationToken with Eq::{equal, not_equal}
///|
pub extend LocalizationToken with @debug.Debug::{to_repr}
///|
pub extend Localization with Eq::{equal, not_equal}
///|
pub extend Localization with @debug.Debug::{to_repr}
///|
pub extend LocalizationIssue with Eq::{equal, not_equal}
///|
pub extend LocalizationIssue with @debug.Debug::{to_repr}
///|
fn localization_validate(document : Localization) -> Unit raise {
if document.tokens.length() > 100000 ||
document.language.is_empty() ||
document.language.char_length() > 1048576 ||
document.language.to_array().any(c => c.to_int() < 32 || c.to_int() == 127) {
raise InvalidDocument("Invalid localization language or token budget")
}
let names : Map[String, Bool] = Map([])
let mut chars = document.language.char_length()
for token in document.tokens {
if token.name.is_empty() || names.contains(token.name.to_lower()) {
raise InvalidDocument("Duplicate or empty localization token")
}
names[token.name.to_lower()] = true
chars += token.name.char_length() + token.text.char_length()
if chars > 8388608 ||
token.name.contains("\u0000") ||
token.text.contains("\u0000") {
raise InvalidDocument("Localization exceeds input budget or contains NUL")
}
}
}
///|
/// BOM determines UTF-16 endianness. UTF-8 without a BOM remains valid; malformed
/// Unicode is rejected, never replaced with lossy text.
pub fn read_localization(bytes : Bytes) -> Localization raise {
if bytes.length() > 16777216 {
raise InvalidDocument("Localization exceeds byte budget")
}
let encoding = if bytes.length() >= 2 && bytes[:2].to_owned() == b"\xff\xfe" {
"utf16le"
} else if bytes.length() >= 2 && bytes[:2].to_owned() == b"\xfe\xff" {
"utf16be"
} else if bytes.length() >= 3 && bytes[:3].to_owned() == b"\xef\xbb\xbf" {
"utf8-bom"
} else {
"utf8"
}
let text = match encoding {
"utf16le" =>
@utf16.decode(bytes[2:], ignore_bom=false, endianness=@utf16.Little)
"utf16be" =>
@utf16.decode(bytes[2:], ignore_bom=false, endianness=@utf16.Big)
"utf8-bom" => @utf8.decode(bytes[3:], ignore_bom=false)
_ => @utf8.decode(bytes[:], ignore_bom=false)
}
let document = parse(text)
guard document.entries.length() == 1 &&
document.entries[0].key.to_lower() == "lang" &&
document.entries[0].value is Object(entries) &&
game_child(entries, "Language") is Some(Text(language)) &&
game_child(entries, "Tokens") is Some(Object(values)) else {
raise InvalidDocument(
"Expected lang/Language/Tokens localization structure",
)
}
let tokens = values.map(e => {
guard e.value is Text(text) else {
raise InvalidDocument("Localization tokens must be strings")
}
LocalizationToken::{ name: e.key, text, }
})
let result = Localization::{ language, tokens, encoding, }
localization_validate(result)
result
}
///|
pub fn Localization::write(
self : Localization,
encoding~ : String = self.encoding,
) -> Bytes raise {
localization_validate(self)
let text = stringify({
entries: [
{
key: "lang",
value: Object([
{ key: "Language", value: Text(self.language), },
{
key: "Tokens",
value: Object(
self.tokens.map(t => { key: t.name, value: Text(t.text), }),
),
},
]),
},
],
})
match encoding {
"utf8" => @utf8.encode(text[:])
"utf8-bom" => {
let out = @buffer.Buffer()
out.write_bytes(b"\xef\xbb\xbf"[:])
out.write_bytes(@utf8.encode(text[:])[:])
out.to_bytes()
}
"utf16le" => @utf16.encode(text[:], bom=true, endianness=@utf16.Little)
"utf16be" => @utf16.encode(text[:], bom=true, endianness=@utf16.Big)
_ => raise InvalidDocument("Expected utf8, utf8-bom, utf16le or utf16be")
}
}
///|
fn localization_digit(c : Char) -> Bool {
c >= '0' && c <= '9'
}
///|
/// Validate Valve indexed %s1/%d2, printf argument identities, or brace tokens.
/// Sorted identities retain multiplicity, so dropped/repeated parameters differ.
pub fn localization_placeholders(
text : String,
syntax~ : String = "valve",
) -> Array[String] raise {
if text.char_length() > 1048576 {
raise InvalidDocument("Placeholder input exceeds token budget")
}
if !["valve", "printf", "brace"].contains(syntax) {
raise InvalidDocument("Unknown placeholder syntax")
}
let chars = text.to_array()
let output : Array[String] = []
let mut i = 0
let mut sequential = 1
let mut positional = false
let mut ordinary = false
while i < chars.length() {
let c = chars[i]
i += 1
if syntax == "brace" {
if c == '{' {
if chars.get(i) == Some('{') {
i += 1
continue
}
let start = i
while i < chars.length() && chars[i] != '}' {
if chars[i] == '{' {
raise InvalidDocument("Nested brace placeholder")
}
i += 1
}
if i == chars.length() || i == start {
raise InvalidDocument("Unclosed or empty brace placeholder")
}
let key = String::from_array(chars[start:i].to_owned())
if chars[start:i].any(ch => {
!((ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z') ||
localization_digit(ch) ||
['_', ':', '-', '.'].contains(ch))
}) {
raise InvalidDocument("Invalid brace placeholder")
}
output.push(key)
i += 1
} else if c == '}' {
if chars.get(i) != Some('}') {
raise InvalidDocument("Unmatched closing brace")
}
i += 1
}
continue
}
if c != '%' {
continue
}
if chars.get(i) == Some('%') {
i += 1
continue
}
if syntax == "valve" {
if chars.get(i).map(ch => ['s', 'd', 'i', 'f'].contains(ch)) != Some(true) {
continue
}
let kind = chars[i]
i += 1
let start = i
while i < chars.length() && localization_digit(chars[i]) {
i += 1
}
if i == start || chars[start] == '0' {
raise InvalidDocument("Valve placeholder requires a positive index")
}
output.push(
kind.to_string() + ":" + String::from_array(chars[start:i].to_owned()),
)
continue
}
let start = i
while i < chars.length() && localization_digit(chars[i]) {
i += 1
}
let argument = if i > start && chars.get(i) == Some('$') {
positional = true
i += 1
let n = @strconv.parse_int(
String::from_array(chars[start:i - 1].to_owned())[:],
)
if n < 1 {
raise InvalidDocument("Printf argument index must be positive")
}
n
} else {
i = start
ordinary = true
let n = sequential
sequential += 1
n
}
while i < chars.length() &&
['-', '+', ' ', '#', '0', '\''].contains(chars[i]) {
i += 1
}
if chars.get(i) == Some('*') {
raise InvalidDocument(
"Dynamic printf widths require an explicit format adapter",
)
}
while i < chars.length() && localization_digit(chars[i]) {
i += 1
}
if chars.get(i) == Some('.') {
i += 1
if chars.get(i) == Some('*') {
raise InvalidDocument("Dynamic printf precision is unsupported")
}
while i < chars.length() && localization_digit(chars[i]) {
i += 1
}
}
let length_start = i
while i < chars.length() &&
['h', 'l', 'j', 'z', 't', 'L'].contains(chars[i]) {
i += 1
}
let length = String::from_array(chars[length_start:i].to_owned())
if i == chars.length() ||
![
'd', 'i', 'u', 'o', 'x', 'X', 'f', 'F', 'e', 'E', 'g', 'G', 'a', 'A', 'c',
's', 'p',
].contains(chars[i]) {
raise InvalidDocument("Unsupported or incomplete printf placeholder")
}
let kind = if ['d', 'i'].contains(chars[i]) {
"d"
} else if ['u', 'o', 'x', 'X'].contains(chars[i]) {
"u"
} else if ['f', 'F', 'e', 'E', 'g', 'G', 'a', 'A'].contains(chars[i]) {
"f"
} else {
chars[i].to_string()
}
output.push(argument.to_string() + ":" + length + kind)
i += 1
}
if positional && ordinary {
raise InvalidDocument("Mixed positional and sequential printf placeholders")
}
output.sort_by((a, b) => a.lexical_compare(b))
output
}
///|
pub fn Localization::validate_translation(
self : Localization,
translated : Localization,
syntax~ : String = "valve",
) -> Array[LocalizationIssue] raise {
localization_validate(self)
localization_validate(translated)
let targets : Map[String, String] = Map([])
for token in translated.tokens {
targets[token.name.to_lower()] = token.text
}
let output : Array[LocalizationIssue] = []
let names : Map[String, Bool] = Map([])
for token in self.tokens {
let key = token.name.to_lower()
names[key] = true
if targets.get(key) is Some(text) {
try {
if localization_placeholders(token.text, syntax~) !=
localization_placeholders(text, syntax~) {
output.push({
token: token.name,
code: "placeholder_mismatch",
message: "Translation changes placeholder identities or multiplicity",
})
}
} catch {
_ =>
output.push({
token: token.name,
code: "invalid_placeholder",
message: "Source or translation has an invalid placeholder",
})
}
} else {
output.push({
token: token.name,
code: "missing_token",
message: "Translation is missing this token",
})
}
}
for token in translated.tokens {
if !names.contains(token.name.to_lower()) {
output.push({
token: token.name,
code: "extra_token",
message: "Translation token is absent from the source catalog",
})
}
}
output
}