///|
fn lint_issue(
severity : LintSeverity,
code : String,
key : String,
message : String,
argument? : String,
selector? : String,
) -> LintIssue {
{ severity, code, key, message, argument, selector }
}
///|
pub fn LintOptions::default() -> LintOptions {
{
report_extra_keys: true,
report_extra_arguments: true,
report_extra_selectors: true,
require_locale_categories: true,
}
}
///|
pub fn LintOptions::minimal() -> LintOptions {
{
report_extra_keys: false,
report_extra_arguments: false,
report_extra_selectors: false,
require_locale_categories: false,
}
}
///|
pub fn LintOptions::report_extra_keys(self : LintOptions) -> Bool {
self.report_extra_keys
}
///|
pub fn LintOptions::report_extra_arguments(self : LintOptions) -> Bool {
self.report_extra_arguments
}
///|
pub fn LintOptions::report_extra_selectors(self : LintOptions) -> Bool {
self.report_extra_selectors
}
///|
pub fn LintOptions::require_locale_categories(self : LintOptions) -> Bool {
self.require_locale_categories
}
///|
fn signature_result(
template : String,
) -> Result[MessageSignature, MessageDiagnostic] {
try parse_message(template) catch {
error => Err(diagnostic_from_error(template, error))
} noraise {
message => Ok(message.signature())
}
}
///|
fn role_name_set(roles : Array[ArgumentRole]) -> Map[String, Unit] {
let output : Map[String, Unit] = Map([])
for role in roles {
output[role.name()] = ()
}
output
}
///|
fn missing_role_names(
expected : Array[ArgumentRole],
actual : Array[ArgumentRole],
) -> Array[String] {
let actual_names = role_name_set(actual)
let missing : Array[String] = []
for role in expected {
if !actual_names.contains(role.name()) {
missing.push(role.name())
}
}
missing.sort()
missing
}
///|
fn extra_role_names(
expected : Array[ArgumentRole],
actual : Array[ArgumentRole],
) -> Array[String] {
missing_role_names(actual, expected)
}
///|
fn argument_has_numeric_role(argument : ArgumentUse) -> Bool {
argument.uses_role(CardinalSelector) || argument.uses_role(OrdinalSelector)
}
///|
fn argument_has_text_selector(argument : ArgumentUse) -> Bool {
argument.uses_role(SelectSelector)
}
///|
fn lint_argument_roles(
issues : Array[LintIssue],
key : String,
reference : ArgumentUse,
translation : ArgumentUse,
) -> Unit {
let missing = missing_role_names(reference.roles, translation.roles)
let extra = extra_role_names(reference.roles, translation.roles)
if argument_has_numeric_role(reference) !=
argument_has_numeric_role(translation) {
issues.push(
lint_issue(
Error,
"argument-type-mismatch",
key,
"Argument '\{reference.name}' changes between numeric and text use.",
argument=reference.name,
),
)
}
if missing.length() > 0 {
issues.push(
lint_issue(
Error,
"missing-argument-role",
key,
"Argument '\{reference.name}' is missing roles: \{missing.join(", ")}.",
argument=reference.name,
),
)
}
if extra.length() > 0 {
issues.push(
lint_issue(
Warning,
"extra-argument-role",
key,
"Argument '\{reference.name}' has extra roles: \{extra.join(", ")}.",
argument=reference.name,
),
)
}
if argument_has_numeric_role(translation) &&
argument_has_text_selector(translation) {
issues.push(
lint_issue(
Error,
"conflicting-argument-roles",
key,
"Argument '\{reference.name}' is used as both numeric and select selector.",
argument=reference.name,
),
)
}
}
///|
fn lint_arguments(
issues : Array[LintIssue],
key : String,
reference : MessageSignature,
translation : MessageSignature,
options : LintOptions,
) -> Unit {
for expected in reference.arguments {
match translation.argument(expected.name) {
None =>
issues.push(
lint_issue(
Error,
"missing-argument",
key,
"Missing argument: \{expected.name}",
argument=expected.name,
),
)
Some(actual) => lint_argument_roles(issues, key, expected, actual)
}
}
if options.report_extra_arguments {
for actual in translation.arguments {
if !reference.has_argument(actual.name) {
issues.push(
lint_issue(
Error,
"extra-argument",
key,
"Unexpected argument: \{actual.name}",
argument=actual.name,
),
)
}
}
}
}
///|
fn choice_occurrence_before(
choices : Array[ChoiceUse],
current : ChoiceUse,
) -> Int {
let mut occurrence = 0
for candidate in choices {
if candidate.ordinal >= current.ordinal {
break
}
if candidate.argument == current.argument && candidate.kind == current.kind {
occurrence += 1
}
}
occurrence
}
///|
fn nth_choice(
signature : MessageSignature,
argument : String,
kind : ChoiceKind,
occurrence : Int,
) -> ChoiceUse? {
let mut current = 0
for choice in signature.choices {
if choice.argument == argument && choice.kind == kind {
if current == occurrence {
return Some(choice)
}
current += 1
}
}
None
}
///|
fn choice_with_other_kind(
signature : MessageSignature,
argument : String,
kind : ChoiceKind,
) -> ChoiceUse? {
for choice in signature.choices {
if choice.argument == argument && choice.kind != kind {
return Some(choice)
}
}
None
}
///|
fn selector_required_from_reference(
kind : ChoiceKind,
selector : String,
) -> Bool {
match kind {
Select => selector != "other"
Plural | SelectOrdinal => selector.has_prefix("=")
}
}
///|
fn lint_reference_selectors(
issues : Array[LintIssue],
key : String,
expected : ChoiceUse,
actual : ChoiceUse,
options : LintOptions,
) -> Unit {
for selector in expected.selectors {
if selector_required_from_reference(expected.kind, selector) &&
!actual.has_selector(selector) {
issues.push(
lint_issue(
Error,
"missing-selector",
key,
"Choice '\{expected.argument}' is missing selector '\{selector}'.",
argument=expected.argument,
selector~,
),
)
}
}
if options.report_extra_selectors && expected.kind == Select {
for selector in actual.selectors {
if selector != "other" && !expected.has_selector(selector) {
issues.push(
lint_issue(
Warning,
"extra-selector",
key,
"Choice '\{expected.argument}' has unexpected selector '\{selector}'.",
argument=expected.argument,
selector~,
),
)
}
}
}
if options.report_extra_selectors && expected.kind != Select {
for selector in actual.exact_selectors() {
if !expected.has_selector(selector) {
issues.push(
lint_issue(
Warning,
"extra-exact-selector",
key,
"Choice '\{expected.argument}' adds exact selector '\{selector}'.",
argument=expected.argument,
selector~,
),
)
}
}
}
}
///|
fn required_categories_for_choice(
locale : String,
kind : ChoiceKind,
) -> Array[String] {
match kind {
Select => []
Plural => required_cardinal_categories(locale)
SelectOrdinal => required_ordinal_categories(locale)
}
}
///|
fn lint_locale_categories(
issues : Array[LintIssue],
key : String,
locale : String,
choice : ChoiceUse,
) -> Unit {
for category in required_categories_for_choice(locale, choice.kind) {
if !choice.has_selector(category) {
issues.push(
lint_issue(
Warning,
"missing-plural-category",
key,
"Locale '\{locale}' expects '\{category}' for \{choice.kind.name()} argument '\{choice.argument}'.",
argument=choice.argument,
selector=category,
),
)
}
}
}
///|
fn lint_choices(
issues : Array[LintIssue],
key : String,
locale : String,
reference : MessageSignature,
translation : MessageSignature,
options : LintOptions,
) -> Unit {
for expected in reference.choices {
let occurrence = choice_occurrence_before(reference.choices, expected)
match
nth_choice(translation, expected.argument, expected.kind, occurrence) {
Some(actual) =>
lint_reference_selectors(issues, key, expected, actual, options)
None =>
match
choice_with_other_kind(translation, expected.argument, expected.kind) {
Some(actual) =>
issues.push(
lint_issue(
Error,
"choice-kind-mismatch",
key,
"Argument '\{expected.argument}' changed from \{expected.kind.name()} to \{actual.kind.name()}.",
argument=expected.argument,
),
)
None =>
issues.push(
lint_issue(
Error,
"missing-choice",
key,
"Missing \{expected.kind.name()} choice for argument '\{expected.argument}'.",
argument=expected.argument,
),
)
}
}
}
if options.require_locale_categories {
for choice in translation.choices {
lint_locale_categories(issues, key, locale, choice)
}
}
}
///|
fn lint_valid_templates(
issues : Array[LintIssue],
key : String,
locale : String,
reference : MessageSignature,
translation : MessageSignature,
options : LintOptions,
) -> Unit {
lint_arguments(issues, key, reference, translation, options)
lint_choices(issues, key, locale, reference, translation, options)
}
///|
fn lint_entry(
issues : Array[LintIssue],
key : String,
reference_template : String,
translated_template : String,
locale : String,
options : LintOptions,
) -> Unit {
match
(
signature_result(reference_template),
signature_result(translated_template),
) {
(Err(diagnostic), _) =>
issues.push(
lint_issue(
Error,
"invalid-reference-template",
key,
diagnostic.display(),
),
)
(_, Err(diagnostic)) =>
issues.push(
lint_issue(Error, "invalid-template", key, diagnostic.display()),
)
(Ok(reference), Ok(translation)) =>
lint_valid_templates(issues, key, locale, reference, translation, options)
}
}
///|
/// Compare a translation against the reference catalog with explicit policy.
pub fn lint_catalogs_with_options(
reference : Catalog,
translation : Catalog,
options : LintOptions,
) -> LintReport {
let issues : Array[LintIssue] = []
for key in reference.keys() {
let reference_template = reference.entries[key]
match translation.entries.get(key) {
None =>
issues.push(
lint_issue(Error, "missing-key", key, "Missing translation."),
)
Some(translated_template) =>
lint_entry(
issues,
key,
reference_template,
translated_template,
translation.locale,
options,
)
}
}
if options.report_extra_keys {
for key in translation.keys() {
if !reference.contains(key) {
issues.push(
lint_issue(
Warning,
"extra-key",
key,
"No reference message for this key.",
),
)
}
}
}
{ reference_locale: reference.locale, locale: translation.locale, issues }
}
///|
/// Compare a translation against the reference catalog.
pub fn lint_catalogs(reference : Catalog, translation : Catalog) -> LintReport {
lint_catalogs_with_options(reference, translation, LintOptions::default())
}
///|
/// Lint multiple translations using one reference catalog.
pub fn lint_catalog_set(
reference : Catalog,
translations : Array[Catalog],
) -> Array[LintReport] {
let reports : Array[LintReport] = []
for translation in translations {
reports.push(lint_catalogs(reference, translation))
}
reports
}
///|
pub fn LintSeverity::name(self : LintSeverity) -> String {
match self {
Error => "error"
Warning => "warning"
Information => "information"
}
}
///|
pub fn LintSeverity::rank(self : LintSeverity) -> Int {
match self {
Error => 3
Warning => 2
Information => 1
}
}
///|
pub fn LintIssue::severity(self : LintIssue) -> LintSeverity {
self.severity
}
///|
pub fn LintIssue::code(self : LintIssue) -> String {
self.code
}
///|
pub fn LintIssue::key(self : LintIssue) -> String {
self.key
}
///|
pub fn LintIssue::message(self : LintIssue) -> String {
self.message
}
///|
pub fn LintIssue::argument(self : LintIssue) -> String? {
self.argument
}
///|
pub fn LintIssue::selector(self : LintIssue) -> String? {
self.selector
}
///|
pub fn LintIssue::display(self : LintIssue) -> String {
"\{self.severity.name()} \{self.code} \{self.key}: \{self.message}"
}
///|
pub fn LintReport::ok(self : LintReport) -> Bool {
self.issues.length() == 0
}
///|
pub fn LintReport::has_errors(self : LintReport) -> Bool {
for issue in self.issues {
if issue.severity == Error {
return true
}
}
false
}
///|
pub fn LintReport::reference_locale(self : LintReport) -> String {
self.reference_locale
}
///|
pub fn LintReport::locale(self : LintReport) -> String {
self.locale
}
///|
pub fn LintReport::issues(self : LintReport) -> Array[LintIssue] {
self.issues
}
///|
pub fn LintReport::issues_with_severity(
self : LintReport,
severity : LintSeverity,
) -> Array[LintIssue] {
let output : Array[LintIssue] = []
for issue in self.issues {
if issue.severity == severity {
output.push(issue)
}
}
output
}
///|
pub fn LintReport::issues_with_code(
self : LintReport,
code : String,
) -> Array[LintIssue] {
let output : Array[LintIssue] = []
for issue in self.issues {
if issue.code == code {
output.push(issue)
}
}
output
}
///|
pub fn LintReport::contains_code(self : LintReport, code : String) -> Bool {
for issue in self.issues {
if issue.code == code {
return true
}
}
false
}
///|
pub fn LintReport::summary(self : LintReport) -> LintSummary {
let mut errors = 0
let mut warnings = 0
let mut information = 0
for issue in self.issues {
match issue.severity {
Error => errors += 1
Warning => warnings += 1
Information => information += 1
}
}
{ errors, warnings, information, total: self.issues.length() }
}
///|
pub fn LintSummary::errors(self : LintSummary) -> Int {
self.errors
}
///|
pub fn LintSummary::warnings(self : LintSummary) -> Int {
self.warnings
}
///|
pub fn LintSummary::information(self : LintSummary) -> Int {
self.information
}
///|
pub fn LintSummary::total(self : LintSummary) -> Int {
self.total
}
///|
pub fn LintSummary::display(self : LintSummary) -> String {
"\{self.errors} errors, \{self.warnings} warnings, \{self.information} information"
}
///|
pub fn LintReport::exit_code(self : LintReport) -> Int {
if self.has_errors() {
2
} else if self.issues.length() > 0 {
1
} else {
0
}
}
///|
pub fn LintReport::to_text(self : LintReport) -> String {
if self.issues.length() == 0 {
return "OK"
}
let output = StringBuilder::new()
for index, issue in self.issues {
if index > 0 {
output.write_char('\n')
}
output.write_string(issue.display())
}
output.write_char('\n')
output.write_string(self.summary().display())
output.to_string()
}
///|
fn write_json_optional_string(
output : StringBuilder,
name : String,
value : String?,
) -> Unit {
match value {
Some(text) => {
output.write_char(',')
write_json_string(output, name)
output.write_char(':')
write_json_string(output, text)
}
None => ()
}
}
///|
fn write_lint_issue_json(output : StringBuilder, issue : LintIssue) -> Unit {
output.write_char('{')
write_json_string(output, "severity")
output.write_char(':')
write_json_string(output, issue.severity.name())
output.write_char(',')
write_json_string(output, "code")
output.write_char(':')
write_json_string(output, issue.code)
output.write_char(',')
write_json_string(output, "key")
output.write_char(':')
write_json_string(output, issue.key)
output.write_char(',')
write_json_string(output, "message")
output.write_char(':')
write_json_string(output, issue.message)
write_json_optional_string(output, "argument", issue.argument)
write_json_optional_string(output, "selector", issue.selector)
output.write_char('}')
}
///|
pub fn LintReport::to_json(self : LintReport) -> String {
let summary = self.summary()
let output = StringBuilder::new()
output.write_char('{')
write_json_string(output, "reference_locale")
output.write_char(':')
write_json_string(output, self.reference_locale)
output.write_char(',')
write_json_string(output, "locale")
output.write_char(':')
write_json_string(output, self.locale)
output.write_char(',')
write_json_string(output, "ok")
output.write_char(':')
output.write_string(if self.ok() { "true" } else { "false" })
output.write_char(',')
write_json_string(output, "errors")
output.write_char(':')
output.write_string(summary.errors.to_string())
output.write_char(',')
write_json_string(output, "warnings")
output.write_char(':')
output.write_string(summary.warnings.to_string())
output.write_char(',')
write_json_string(output, "issues")
output.write_string(":[")
for index, issue in self.issues {
if index > 0 {
output.write_char(',')
}
write_lint_issue_json(output, issue)
}
output.write_string("]}")
output.to_string()
}