///|
pub fn validate_options(options : Options) -> Array[String] {
let errors : Array[String] = []
if options.plural_max < 0 || options.plural_max > 100000 {
errors.push("plural_max must be in 0..100000")
}
if options.expression_depth < 1 || options.expression_depth > 128 {
errors.push("expression_depth must be in 1..128")
}
if options.expression_budget < 1 || options.expression_budget > 1000000 {
errors.push("expression_budget must be in 1..1000000")
}
if options.max_entries < 1 || options.max_entries > 1000000 {
errors.push("max_entries must be in 1..1000000")
}
if options.max_line_chars < 1 || options.max_line_chars > 4194304 {
errors.push("max_line_chars must be in 1..4194304")
}
errors
}
///|
fn check_format_entry(
entry : Entry,
index : Int32,
catalog : Catalog,
result : Array[Diagnostic],
) -> Unit {
for style in ["c-format", "python-format", "python-brace-format"] {
if entry.has_flag(style) && entry.has_flag("no-" + style) {
result.push(
issue(
"F008",
Error,
"Entry has contradictory format flags for " + style,
catalog.file,
entry.span,
entry=index,
),
)
}
if entry.has_flag("possible-" + style) && !entry.has_flag(style) {
result.push(
issue(
"F007",
Warning,
"Possible format flag was not checked: " + style,
catalog.file,
entry.id_span,
entry=index,
hint="Confirm the source contract and replace possible- with an explicit format flag.",
),
)
}
}
for flag in entry.flags {
if flag.has_suffix("-format") &&
flag != "c-format" &&
flag != "python-brace-format" &&
flag != "python-format" &&
!flag.has_prefix("no-") &&
!flag.has_prefix("possible-") {
result.push(
issue(
"F007",
Warning,
"Format style is not checked in this release: " + flag,
catalog.file,
entry.id_span,
entry=index,
),
)
}
}
for style in ["c-format", "python-brace-format", "python-format"] {
if !entry.has_flag(style) {
continue
}
let source = format_contract(style, entry.id)
let contract = match source {
Ok(contract) => contract
Err(error) => {
result.push(
issue(
"F001",
Error,
"Invalid or unsupported source format: " + error,
catalog.file,
entry.id_span,
entry=index,
),
)
continue
}
}
if entry.plural is Some(plural) {
let plural_contract = format_contract(style, plural)
match plural_contract {
Err(error) =>
result.push(
issue(
"F001",
Error,
"Invalid or unsupported plural source format: " + error,
catalog.file,
entry.plural_span.unwrap_or(entry.span),
entry=index,
),
)
Ok(actual) =>
for difference in compare_contracts(contract, actual) {
result.push(
issue(
difference.code,
Error,
"Singular/plural source mismatch: " + difference.message,
catalog.file,
entry.plural_span.unwrap_or(entry.span),
entry=index,
related=entry.id_span,
),
)
}
}
}
for translation in entry.translations {
if translation.value == "" {
continue
}
let target = format_contract(style, translation.value)
match target {
Err(error) =>
result.push(
issue(
"F002",
Error,
"Invalid or unsupported translation format: " + error,
catalog.file,
translation.span,
entry=index,
related=entry.id_span,
),
)
Ok(target) =>
for difference in compare_contracts(contract, target) {
result.push(
issue(
difference.code,
Error,
difference.message,
catalog.file,
translation.span,
entry=index,
related=entry.id_span,
),
)
}
}
}
}
}
///|
fn check_entry_shape(
entry : Entry,
index : Int32,
catalog : Catalog,
nplurals : Int32?,
template : Bool,
options : Options,
result : Array[Diagnostic],
) -> Unit {
if entry.translations.is_empty() {
result.push(
issue(
"C003",
Error,
"Entry has no msgstr field",
catalog.file,
entry.id_span,
entry=index,
),
)
}
if entry.plural is None {
for translation in entry.translations {
if translation.index != 0 {
result.push(
issue(
"C004",
Error,
"Singular entry has a plural translation index",
catalog.file,
translation.span,
entry=index,
),
)
}
}
} else {
if entry.plural == Some("") {
result.push(
issue(
"C005",
Error,
"msgid_plural is empty",
catalog.file,
entry.plural_span.unwrap_or(entry.span),
entry=index,
),
)
}
if !template && nplurals is Some(count) {
for expected = 0; expected < count; expected = expected + 1 {
if entry.translation(expected) is None {
result.push(
issue(
"L003",
Error,
"Missing msgstr[" + expected.to_string() + "]",
catalog.file,
entry.span,
entry=index,
),
)
}
}
for translation in entry.translations {
if translation.index >= count {
result.push(
issue(
"L004",
Error,
"Translation index exceeds nplurals",
catalog.file,
translation.span,
entry=index,
),
)
}
}
}
}
if entry.has_flag("fuzzy") && !template {
result.push(
issue(
"C006",
options.fuzzy_severity,
"Translation is marked fuzzy and needs review",
catalog.file,
entry.span,
entry=index,
),
)
}
for translation in entry.translations {
if translation.value == "" && !template {
result.push(
issue(
"C007",
options.empty_severity,
"Translation is empty at index " + translation.index.to_string(),
catalog.file,
translation.span,
entry=index,
),
)
continue
}
if template && translation.value != "" {
result.push(
issue(
"C008",
Warning,
"Template contains a translated msgstr",
catalog.file,
translation.span,
entry=index,
),
)
}
if translation.value != "" {
if entry.id.has_suffix("\n") != translation.value.has_suffix("\n") {
result.push(
issue(
"C010",
Error,
"Source and translation disagree on a final newline",
catalog.file,
translation.span,
entry=index,
related=entry.id_span,
),
)
}
if entry.id.has_prefix("\n") != translation.value.has_prefix("\n") {
result.push(
issue(
"C011",
Warning,
"Source and translation disagree on an initial newline",
catalog.file,
translation.span,
entry=index,
related=entry.id_span,
),
)
}
}
check_control_characters(
translation.value,
catalog.file,
translation.span,
index,
result,
)
}
check_control_characters(entry.id, catalog.file, entry.id_span, index, result)
if entry.context is Some(context) {
check_control_characters(context, catalog.file, entry.span, index, result)
}
if entry.plural is Some(plural) {
check_control_characters(
plural,
catalog.file,
entry.plural_span.unwrap_or(entry.span),
index,
result,
)
if entry.id.has_suffix("\n") != plural.has_suffix("\n") {
result.push(
issue(
"C010",
Error,
"Singular and plural sources disagree on a final newline",
catalog.file,
entry.plural_span.unwrap_or(entry.span),
entry=index,
related=entry.id_span,
),
)
}
}
}
///|
fn check_control_characters(
text : String,
file : String,
span : Span,
index : Int32,
result : Array[Diagnostic],
) -> Unit {
if text.to_array().any(c => c == '\u0000') {
result.push(
issue(
"C012",
Error,
"Catalog strings must not contain NUL",
file,
span,
entry=index,
),
)
}
if text.to_array().any(c => c == '\r') {
result.push(
issue(
"C013",
Warning,
"String contains a carriage return",
file,
span,
entry=index,
hint="Use newline escapes unless a carriage return is intentional.",
),
)
}
}
///|
fn report_with(
catalog : Catalog,
diagnostics : Array[Diagnostic],
options : Options,
checked : Int32?,
) -> Report {
let stats = statistics(catalog, diagnostics)
let exit_code = if diagnostics.any(d => d.code == "O001") {
2
} else if stats.errors > 0 || (options.strict && stats.warnings > 0) {
1
} else {
0
}
{ catalog, diagnostics, stats, exit_code, plural_checked_through: checked, }
}
///|
/// Validate a parsed catalog. Obsolete entries are retained but not checked.
pub fn check_catalog(
catalog : Catalog,
options~ : Options = default_options(),
template~ : Bool = false,
) -> Report {
let result = catalog.diagnostics.copy()
let option_errors = validate_options(options)
if !option_errors.is_empty() {
for error in option_errors {
result.push(issue("O001", Error, error, catalog.file, line_span(1, 1, 0)))
}
return report_with(catalog, result, options, None)
}
let header = read_header(catalog)
for diagnostic in validate_header(catalog, header, template~) {
result.push(diagnostic)
}
let mut count : Int32? = None
let mut checked : Int32? = None
let has_plural = catalog.entries.any(e => !e.obsolete && e.plural is Some(_))
if !template {
match header.get("Plural-Forms") {
None =>
if has_plural {
result.push(
issue(
"L001",
Error,
"Plural entries require Plural-Forms metadata",
catalog.file,
header_span(catalog),
),
)
}
Some(value) =>
match parse_plural_rule(value, maximum_depth=options.expression_depth) {
Err(error) =>
result.push(
issue("L002", Error, error, catalog.file, header_span(catalog)),
)
Ok(rule) => {
count = Some(rule.nplurals)
match
plural_coverage(
rule,
maximum=options.plural_max,
budget=options.expression_budget,
maximum_depth=options.expression_depth,
) {
Err(error) =>
result.push(
issue(
"L002",
Error,
error,
catalog.file,
header_span(catalog),
),
)
Ok(coverage) => {
if coverage.failures.is_empty() {
checked = Some(coverage.maximum)
}
for failure in coverage.failures {
result.push(
issue(
"L006",
Error,
failure,
catalog.file,
header_span(catalog),
),
)
}
for form = 0; form < rule.nplurals; form = form + 1 {
if !coverage.reached.contains(form) {
result.push(
issue(
"L005",
Warning,
"Plural form " +
form.to_string() +
" was not reached in the tested interval",
catalog.file,
header_span(catalog),
hint="Finite sampling is not a proof of unreachability for every n.",
),
)
}
}
}
}
}
}
}
}
let keys : Map[String, Int32] = Map([])
for index, entry in catalog.entries {
if entry.obsolete {
continue
}
let key = entry_key(entry)
if keys.get(key) is Some(previous) {
result.push(
issue(
"C001",
Error,
"Duplicate msgctxt/msgid pair",
catalog.file,
entry.id_span,
entry=index,
related=catalog.entries[previous].id_span,
),
)
} else {
keys.set(key, index)
}
if entry.is_header() {
if entry.translations.length() != 1 {
result.push(
issue(
"C003",
Error,
"Header requires exactly one msgstr field",
catalog.file,
entry.id_span,
entry=index,
),
)
}
if entry.plural is Some(_) || entry.translations.any(t => t.index != 0) {
result.push(
issue(
"C002",
Error,
"Header must be a singular untranslated-id entry",
catalog.file,
entry.span,
entry=index,
),
)
}
continue
}
check_entry_shape(entry, index, catalog, count, template, options, result)
check_format_entry(entry, index, catalog, result)
}
report_with(catalog, result, options, checked)
}
///|
pub fn check_text(
text : String,
file~ : String = "",
options~ : Options = default_options(),
template~ : Bool = false,
) -> Report {
check_catalog(parse_catalog(text, file~, options~), options~, template~)
}