///|
pub(all) struct ConsistencyOptions {
enabled : Bool
ignore_context : Bool
inverse : Bool
max_entries : Int32
} derive(Debug)
///|
pub fn default_consistency_options() -> ConsistencyOptions {
{
enabled: false,
ignore_context: false,
inverse: false,
max_entries: 100000,
}
}
///|
pub(all) struct ProjectFinding {
file_index : Int32
diagnostic : Diagnostic
related_file : String?
related_span : Span?
related_entry : Int32?
} derive(Debug)
///|
priv struct ConsistencyReference {
file_index : Int32
entry_index : Int32
}
///|
priv struct ConsistencyWitness {
reference : ConsistencyReference
value : String
}
///|
priv struct ConsistencyWitnesses {
first : ConsistencyWitness
outside_first_file : Array[ConsistencyWitness]
outside_first_value : Array[ConsistencyWitness]
}
///|
fn consistency_witness(
groups : Map[String, ConsistencyWitnesses],
key : String,
current : ConsistencyReference,
value : String,
) -> ConsistencyReference? {
let witness = { reference: current, value, }
if groups.get(key) is Some(group) {
let witnesses = [group.first]
for previous in group.outside_first_file {
witnesses.push(previous)
}
for previous in group.outside_first_value {
witnesses.push(previous)
}
let mut result : ConsistencyReference? = None
for previous in witnesses {
if previous.reference.file_index != current.file_index &&
previous.value != value {
result = Some(previous.reference)
break
}
}
// When the query shares first's file, two distinct outside values suffice.
// When it shares first's value, two distinct other files suffice.
// All other queries can use first: at most five retained witnesses per key.
if current.file_index != group.first.reference.file_index &&
group.outside_first_file.length() < 2 &&
!group.outside_first_file.any(previous => previous.value == value) {
group.outside_first_file.push(witness)
}
if value != group.first.value &&
group.outside_first_value.length() < 2 &&
!group.outside_first_value.any(previous => {
previous.reference.file_index == current.file_index
}) {
group.outside_first_value.push(witness)
}
result
} else {
groups.set(key, {
first: witness,
outside_first_file: [],
outside_first_value: [],
})
None
}
}
///|
fn consistency_locale(catalog : Catalog) -> String? {
let value = match read_header(catalog).get("Language") {
None => return None
Some(value) => trim_text(value)
}
if value == "" ||
value
.to_array()
.any(c => {
!((c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '_')
}) {
return None
}
let normalized = value
.to_lower()
.split("_")
.map(s => s.to_owned())
.to_array()
.join("-")
let parts = normalized.split("-").map(s => s.to_owned()).to_array()
if parts.any(part => part == "") ||
parts[0].to_array().any(c => !(c >= 'a' && c <= 'z')) {
return None
}
// This is a grouping syntax check, not an ISO/BCP-47 registry validator.
Some(normalized)
}
///|
fn consistency_context(entry : Entry, options : ConsistencyOptions) -> String {
if options.ignore_context {
""
} else {
(if entry.context is Some(_) { "1" } else { "0" }) +
markup_part(entry.context.unwrap_or(""))
}
}
///|
fn consistency_translation(entry : Entry) -> String {
let values = entry.translations.map(t => {
t.index.to_string() + ":" + markup_part(t.value)
})
values.sort()
join_strings(values, "")
}
///|
fn consistency_flags(entry : Entry) -> String {
let flags = entry.flags.filter(flag => flag.has_suffix("-format"))
flags.sort()
join_strings(unique_strings(flags).map(markup_part), "")
}
///|
fn project_finding(
catalogs : Array[Catalog],
file_index : Int32,
entry_index : Int32,
code : String,
severity : Severity,
message : String,
previous : ConsistencyReference?,
) -> ProjectFinding {
let catalog = catalogs[file_index]
let span = if entry_index >= 0 {
catalogs[file_index].entries[entry_index].id_span
} else {
header_span(catalog)
}
let related_file = previous.map(p => catalogs[p.file_index].file)
let related_span = previous.map(p => {
catalogs[p.file_index].entries[p.entry_index].id_span
})
let hint = match previous {
None =>
"Consistency checks require valid Language metadata; no locale is guessed from a filename."
Some(p) =>
"Compare with " +
catalogs[p.file_index].file +
":" +
catalogs[p.file_index].entries[p.entry_index].id_span.start.line.to_string() +
"; review deliberate translation differences before changing text."
}
let diagnostic = if entry_index < 0 {
issue(code, severity, message, catalog.file, span, hint~)
} else {
issue(code, severity, message, catalog.file, span, entry=entry_index, hint~)
}
{
file_index,
diagnostic,
related_file,
related_span,
related_entry: previous.map(p => p.entry_index),
}
}
///|
/// Compare exact translated strings across separate catalogs in the same locale.
pub fn check_consistency(
catalogs : Array[Catalog],
options~ : ConsistencyOptions = {
..default_consistency_options(),
enabled: true,
},
) -> Array[ProjectFinding] {
let result : Array[ProjectFinding] = []
if !options.enabled {
return result
}
if catalogs.is_empty() {
return result
}
if options.max_entries < 1 || options.max_entries > 1000000 {
return [
project_finding(
catalogs,
0,
-1,
"O001",
Error,
"consistency max_entries must be in 1..1000000",
None,
),
]
}
let mut total = 0
for catalog in catalogs {
total += catalog.entries.length()
if total > options.max_entries {
return [
project_finding(
catalogs,
0,
-1,
"X009",
Error,
"Project entry resource limit exceeded; consistency comparison was not run",
None,
),
]
}
}
let plurals : Map[String, ConsistencyWitnesses] = Map([])
let translations : Map[String, ConsistencyWitnesses] = Map([])
let flags_index : Map[String, ConsistencyWitnesses] = Map([])
let targets : Map[String, ConsistencyWitnesses] = Map([])
for file_index, catalog in catalogs {
// Damaged catalogs already fail parsing; never infer conflicts from partial data.
if catalog.entries.is_empty() ||
catalog.diagnostics.any(d => d.severity == Error) {
continue
}
let language = match consistency_locale(catalog) {
Some(value) => value
None => {
result.push(
project_finding(
catalogs,
file_index,
-1,
"X001",
Warning,
"Catalog omitted from consistency groups because Language metadata is missing or invalid",
None,
),
)
continue
}
}
for entry_index, entry in catalog.entries {
if entry.obsolete ||
entry.is_header() ||
entry.has_flag("fuzzy") ||
!entry.is_translated() {
continue
}
let prefix = markup_part(language) +
markup_part(consistency_context(entry, options))
let key = prefix + markup_part(entry.id)
let current = { file_index, entry_index, }
let translation = consistency_translation(entry)
let flags = consistency_flags(entry)
let plural = match entry.plural {
None => "0"
Some(value) => "1" + markup_part(value)
}
if consistency_witness(plurals, key, current, plural) is Some(previous) {
result.push(
project_finding(
catalogs,
file_index,
entry_index,
"X003",
Error,
"Same locale/context/source key has a different plural source across files",
Some(previous),
),
)
}
// Translation differences only compare entries with the same plural source.
// A separate plural witness must not hide a valid translation witness.
if consistency_witness(
translations,
key + markup_part(plural),
current,
translation,
)
is Some(previous) {
result.push(
project_finding(
catalogs,
file_index,
entry_index,
"X002",
Warning,
"Same locale/context/source has conflicting translations across files",
Some(previous),
),
)
}
if consistency_witness(flags_index, key, current, flags) is Some(previous) {
result.push(
project_finding(
catalogs,
file_index,
entry_index,
"X004",
Warning,
"Format flags differ for the same source key across files",
Some(previous),
),
)
}
if options.inverse {
let target_key = prefix + markup_part(translation)
if consistency_witness(targets, target_key, current, entry.id)
is Some(previous) {
result.push(
project_finding(
catalogs,
file_index,
entry_index,
"X005",
Warning,
"Different sources share one translation across files (inverse review)",
Some(previous),
),
)
}
}
}
}
result
}
///|
pub fn project_finding_json(finding : ProjectFinding) -> Json {
{
"diagnostic": diagnostic_json(finding.diagnostic),
"file_index": Json(finding.file_index),
"related": match finding.related_file {
None => Json::null()
Some(file) =>
{
"file": Json(file),
"span": match finding.related_span {
None => Json::null()
Some(span) => span_json(span)
},
"entry": optional_json(finding.related_entry),
}
},
}
}