///|
/// Controls how application text is transformed for localization testing.
pub struct PseudoOptions {
accents : Bool
expand : Bool
wrap : Bool
} derive(Eq, Debug)
///|
pub fn PseudoOptions::new(
accents? : Bool = true,
expand? : Bool = true,
wrap? : Bool = true,
) -> PseudoOptions {
{ accents, expand, wrap }
}
///|
pub fn PseudoOptions::accents(self : PseudoOptions) -> Bool {
self.accents
}
///|
pub fn PseudoOptions::expands(self : PseudoOptions) -> Bool {
self.expand
}
///|
pub fn PseudoOptions::wraps(self : PseudoOptions) -> Bool {
self.wrap
}
///|
fn pseudo_accent(character : Char) -> Char {
match character {
'A' => 'Å'
'B' => 'Ɓ'
'C' => 'Ç'
'D' => 'Ð'
'E' => 'Ë'
'F' => 'Ƒ'
'G' => 'Ĝ'
'H' => 'Ĥ'
'I' => 'Ï'
'J' => 'Ĵ'
'K' => 'Ķ'
'L' => 'Ŀ'
'M' => 'M'
'N' => 'Ñ'
'O' => 'Ø'
'P' => 'Þ'
'Q' => 'Q'
'R' => 'Ŕ'
'S' => 'Š'
'T' => 'Ţ'
'U' => 'Û'
'V' => 'V'
'W' => 'Ŵ'
'X' => 'X'
'Y' => 'Ÿ'
'Z' => 'Ž'
'a' => 'å'
'b' => 'ƀ'
'c' => 'ç'
'd' => 'ð'
'e' => 'ë'
'f' => 'ƒ'
'g' => 'ĝ'
'h' => 'ĥ'
'i' => 'ï'
'j' => 'ĵ'
'k' => 'ķ'
'l' => 'ŀ'
'm' => 'm'
'n' => 'ñ'
'o' => 'ø'
'p' => 'þ'
'q' => 'q'
'r' => 'ŕ'
's' => 'š'
't' => 'ţ'
'u' => 'û'
'v' => 'v'
'w' => 'ŵ'
'x' => 'x'
'y' => 'ÿ'
'z' => 'ž'
other => other
}
}
///|
fn pseudo_is_vowel(character : Char) -> Bool {
match character {
'A' | 'E' | 'I' | 'O' | 'U' | 'a' | 'e' | 'i' | 'o' | 'u' => true
_ => false
}
}
///|
fn transform_pseudo_text(text : String, options : PseudoOptions) -> String {
let output = StringBuilder::new()
for character in text {
let transformed = if options.accents {
pseudo_accent(character)
} else {
character
}
output.write_char(transformed)
if options.expand && pseudo_is_vowel(character) {
output.write_char(transformed)
}
}
output.to_string()
}
///|
/// Transforms plain UI text so untranslated strings and cramped layouts stand
/// out during testing.
pub fn pseudo_localize(
text : String,
options? : PseudoOptions = PseudoOptions::new(),
) -> String {
let transformed = transform_pseudo_text(text, options)
if options.wrap {
"[!! \{transformed} !!]"
} else {
transformed
}
}
///|
fn pseudo_message_nodes(
nodes : Array[MessageNode],
options : PseudoOptions,
) -> Array[MessageNode] {
nodes.map(fn(node) {
match node {
Text(text) => Text(transform_pseudo_text(text, options))
Argument(name) => Argument(name)
Select(name, cases) =>
Select(
name,
cases.map(fn(branch) {
MessageCase::new(
branch.selector,
pseudo_message_nodes(branch.nodes, options),
)
}),
)
Plural(name, offset, kind, cases) =>
Plural(
name,
offset,
kind,
cases.map(fn(branch) {
MessageCase::new(
branch.selector,
pseudo_message_nodes(branch.nodes, options),
)
}),
)
}
})
}
///|
/// Pseudo-localizes literal text while preserving arguments, selectors, plural
/// rules, and the template's original source for diagnostics.
pub fn MessageTemplate::pseudo_localized(
self : MessageTemplate,
options? : PseudoOptions = PseudoOptions::new(),
) -> MessageTemplate {
let transformed = pseudo_message_nodes(self.nodes, options)
let nodes = if options.wrap {
let wrapped : Array[MessageNode] = [Text("[!! ")]
for node in transformed {
wrapped.push(node)
}
wrapped.push(Text(" !!]"))
wrapped
} else {
transformed
}
{ source: self.source, nodes }
}
///|
/// Creates a pseudo-locale catalog without reparsing or modifying arguments.
pub fn MessageCatalog::pseudo_localized(
self : MessageCatalog,
locale : Locale,
options? : PseudoOptions = PseudoOptions::new(),
) -> MessageCatalog {
{
locale,
messages: self.messages.map(fn(message) {
{
key: message.key,
template: message.template.pseudo_localized(options~),
}
}),
}
}
///|
/// A concrete difference between a reference catalog and a translation.
pub(all) enum CatalogAuditIssue {
MissingKey(String, String)
UnexpectedKey(String, String)
ArgumentSetMismatch(String, String, Array[String], Array[String])
} derive(Eq, Debug)
///|
pub fn CatalogAuditIssue::locale_tag(self : CatalogAuditIssue) -> String {
match self {
MissingKey(tag, _)
| UnexpectedKey(tag, _)
| ArgumentSetMismatch(tag, _, _, _) => tag
}
}
///|
pub fn CatalogAuditIssue::key(self : CatalogAuditIssue) -> String {
match self {
MissingKey(_, key)
| UnexpectedKey(_, key)
| ArgumentSetMismatch(_, key, _, _) => key
}
}
///|
pub fn CatalogAuditIssue::message(self : CatalogAuditIssue) -> String {
match self {
MissingKey(tag, key) => "\{tag} is missing message '\{key}'"
UnexpectedKey(tag, key) => "\{tag} has unexpected message '\{key}'"
ArgumentSetMismatch(tag, key, expected, actual) =>
"\{tag} message '\{key}' uses arguments [\{actual.join(", ")}], expected [\{expected.join(", ")}]"
}
}
///|
/// Summary of a reference-to-translations catalog comparison.
pub struct CatalogAudit {
reference_locale : Locale
checked_catalogs : Int
issues : Array[CatalogAuditIssue]
} derive(Eq, Debug)
///|
pub fn CatalogAudit::reference_locale(self : CatalogAudit) -> Locale {
self.reference_locale
}
///|
pub fn CatalogAudit::checked_catalogs(self : CatalogAudit) -> Int {
self.checked_catalogs
}
///|
pub fn CatalogAudit::issues(self : CatalogAudit) -> Array[CatalogAuditIssue] {
self.issues.copy()
}
///|
pub fn CatalogAudit::is_complete(self : CatalogAudit) -> Bool {
self.issues.is_empty()
}
///|
pub fn CatalogAudit::missing_count(self : CatalogAudit) -> Int {
let mut count = 0
for issue in self.issues {
if issue is MissingKey(_, _) {
count = count + 1
}
}
count
}
///|
pub fn CatalogAudit::unexpected_count(self : CatalogAudit) -> Int {
let mut count = 0
for issue in self.issues {
if issue is UnexpectedKey(_, _) {
count = count + 1
}
}
count
}
///|
pub fn CatalogAudit::argument_mismatch_count(self : CatalogAudit) -> Int {
let mut count = 0
for issue in self.issues {
if issue is ArgumentSetMismatch(_, _, _, _) {
count = count + 1
}
}
count
}
///|
fn string_array_contains(values : Array[String], expected : String) -> Bool {
for value in values {
if value == expected {
return true
}
}
false
}
///|
fn same_string_set(left : Array[String], right : Array[String]) -> Bool {
if left.length() != right.length() {
return false
}
for value in left {
if !string_array_contains(right, value) {
return false
}
}
true
}
///|
/// Compares every translation with a source-of-truth catalog.
///
/// Argument order may differ between languages, but names must form the same
/// set so messages cannot fail only after reaching production.
pub fn audit_catalogs(
reference : MessageCatalog,
translations : Array[MessageCatalog],
) -> CatalogAudit {
let issues : Array[CatalogAuditIssue] = []
for catalog in translations {
let tag = catalog.locale.tag()
for message in reference.messages {
match catalog.get(message.key) {
None => issues.push(MissingKey(tag, message.key))
Some(translated) => {
let expected = message.template.arguments()
let actual = translated.arguments()
if !same_string_set(expected, actual) {
issues.push(ArgumentSetMismatch(tag, message.key, expected, actual))
}
}
}
}
for message in catalog.messages {
if !reference.contains(message.key) {
issues.push(UnexpectedKey(tag, message.key))
}
}
}
{
reference_locale: reference.locale,
checked_catalogs: translations.length(),
issues,
}
}