///|
/// Severity assigned to a catalog validation issue.
pub(all) enum IssueSeverity {
IssueInfo
IssueWarning
IssueError
} derive(Debug, Eq)
///|
/// One deterministic catalog validation finding.
pub(all) struct ValidationIssue {
severity : IssueSeverity
entry : Int?
code : String
message : String
} derive(Debug, Eq)
///|
fn issue(
severity : IssueSeverity,
entry : Int?,
code : String,
message : String,
) -> ValidationIssue {
{ severity, entry, code, message }
}
///|
fn gettext_error_message(error : GettextError) -> String {
match error {
PoSyntax(message~, ..) => message
PluralSyntax(message~, ..) => message
MoFormat(message~, ..) => message
Validation(message~) => message
}
}
///|
fn contains_nul(value : String) -> Bool {
value.contains_char('\u{0000}')
}
///|
fn all_translations_empty(entry : PoEntry) -> Bool {
for value in entry.translations {
if value != "" {
return false
}
}
true
}
///|
fn validate_plural_rule_samples(
rule : PluralRule,
issues : Array[ValidationIssue],
) -> Unit {
let mut failed = false
for n in 0..<=200 {
if failed {
break
}
try rule.select(n) catch {
error => {
issues.push(
issue(
IssueError,
None,
"plural-rule-runtime",
"Plural-Forms fails for n=\{n}: \{gettext_error_message(error)}",
),
)
failed = true
}
} noraise {
_ => ()
}
}
}
///|
/// Validate PO/POT semantics used by the parser, MO compiler, and runtime.
///
/// The returned array is stable in document order. Validation checks metadata,
/// plural rules, duplicate context-aware keys, translation arity, fuzzy and
/// obsolete status, embedded NULs, and context separator collisions.
pub fn validate_po(document : PoFile) -> Array[ValidationIssue] {
let issues : Array[ValidationIssue] = []
let metadata = document.metadata()
let mut plural_rule : PluralRule? = None
let mut has_plural_entry = false
match document.header() {
Some(_) => {
match metadata.get("Content-Type") {
Some(content_type) =>
if !content_type.contains("charset=UTF-8") &&
!content_type.contains("charset=utf-8") {
issues.push(
issue(
IssueWarning,
None,
"header-charset",
"moongettext reads and writes MO strings as UTF-8; declare charset=UTF-8",
),
)
}
None =>
issues.push(
issue(
IssueWarning,
None,
"header-content-type",
"metadata header does not declare Content-Type",
),
)
}
match metadata.get("Plural-Forms") {
Some(value) =>
try parse_plural_forms(value) catch {
error =>
issues.push(
issue(
IssueError,
None,
"plural-forms",
"invalid Plural-Forms: \{gettext_error_message(error)}",
),
)
} noraise {
rule => plural_rule = Some(rule)
}
None => ()
}
}
None =>
issues.push(
issue(
IssueWarning,
None,
"missing-header",
"catalog has no empty-msgid metadata header",
),
)
}
let seen : Map[String, Int] = Map([])
for entry_index, entry in document.entries {
if entry.is_header() {
continue
}
let key = catalog_key(entry.msgid, entry.context)
match seen.get(key) {
Some(previous) =>
issues.push(
issue(
IssueError,
Some(entry_index),
"duplicate-key",
"duplicates entry \{previous} for msgid '\{entry.msgid}'",
),
)
None => seen[key] = entry_index
}
if contains_nul(entry.msgid) {
issues.push(
issue(
IssueError,
Some(entry_index),
"nul-msgid",
"msgid contains NUL, which conflicts with the MO plural separator",
),
)
}
match entry.context {
Some(context) =>
if contains_nul(context) || context.contains_char('\u{0004}') {
issues.push(
issue(
IssueError,
Some(entry_index),
"invalid-context",
"context contains a reserved MO separator",
),
)
}
None => ()
}
match entry.msgid_plural {
Some(plural) => {
has_plural_entry = true
if plural == "" {
issues.push(
issue(
IssueError,
Some(entry_index),
"empty-plural-id",
"msgid_plural cannot be empty",
),
)
}
if contains_nul(plural) {
issues.push(
issue(
IssueError,
Some(entry_index),
"nul-plural-id",
"msgid_plural contains NUL",
),
)
}
match plural_rule {
Some(rule) =>
if entry.translations.length() != rule.nplurals {
issues.push(
issue(
IssueError,
Some(entry_index),
"plural-arity",
"entry has \{entry.translations.length()} translations but Plural-Forms declares \{rule.nplurals}",
),
)
}
None => ()
}
}
None =>
if entry.translations.length() > 1 {
issues.push(
issue(
IssueError,
Some(entry_index),
"singular-arity",
"singular entry has more than one translation",
),
)
}
}
for translation in entry.translations {
if contains_nul(translation) {
issues.push(
issue(
IssueError,
Some(entry_index),
"nul-translation",
"translation contains NUL, which conflicts with MO plural storage",
),
)
break
}
}
if entry.is_fuzzy() {
issues.push(
issue(
IssueWarning,
Some(entry_index),
"fuzzy",
"fuzzy entry is omitted from compiled MO output",
),
)
}
if entry.obsolete {
issues.push(
issue(
IssueInfo,
Some(entry_index),
"obsolete",
"obsolete entry is omitted from compiled MO output",
),
)
} else if all_translations_empty(entry) {
issues.push(
issue(
IssueWarning,
Some(entry_index),
"untranslated",
"entry has no non-empty translation",
),
)
}
}
if has_plural_entry && metadata.get("Plural-Forms") is None {
issues.push(
issue(
IssueWarning,
None,
"missing-plural-forms",
"catalog has plural entries but no Plural-Forms metadata; runtime falls back to English",
),
)
}
match plural_rule {
Some(rule) => validate_plural_rule_samples(rule, issues)
None => ()
}
issues
}
///|
/// Return true when at least one validation issue is an error.
pub fn validation_has_errors(issues : ArrayView[ValidationIssue]) -> Bool {
for finding in issues {
if finding.severity == IssueError {
return true
}
}
false
}
///|
fn severity_label(severity : IssueSeverity) -> String {
match severity {
IssueInfo => "INFO"
IssueWarning => "WARNING"
IssueError => "ERROR"
}
}
///|
/// Format findings as a line-oriented CLI report.
pub fn validation_report(issues : ArrayView[ValidationIssue]) -> String {
if issues.is_empty() {
return "OK: no validation issues\n"
}
let output = StringBuilder()
for finding in issues {
let location = match finding.entry {
Some(index) => " entry=\{index}"
None => ""
}
output.write_string(
"[\{severity_label(finding.severity)}]\{location} \{finding.code}: \{finding.message}\n",
)
}
output.to_string()
}
///|
/// Raise a single `Validation` error when a catalog has any error findings.
pub fn validate_po_strict(document : PoFile) -> Unit raise GettextError {
let findings = validate_po(document)
if validation_has_errors(findings) {
raise Validation(message=validation_report(findings))
}
}
///|
/// Validate a document strictly and then compile it to GNU MO bytes.
pub fn compile_mo_checked(
document : PoFile,
endian? : Endian = Little,
) -> Bytes raise GettextError {
validate_po_strict(document)
compile_mo(document, endian~)
}