use std::collections::{BTreeMap, BTreeSet};
use mf2_locale_data::plural::{PluralKind, plural_rules};
use mf2_model::{
Attributes, Declaration, FunctionRef, Key, Message, OptionValue, Options, Pattern, PatternPart,
};
use mf2_syntax::Analysis;
use crate::config::{Config, Missing};
use crate::corpus::LocaleSource;
use crate::features::{Features, defines_option};
use crate::lint::{Level, Lint};
use crate::loader::Record;
use crate::manifest::Built;
use crate::report::{Report, Sink};
pub struct Corpus<'a> {
pub sources: &'a [LocaleSource],
pub models: &'a [Vec<Option<Message<'a>>>],
pub indexes: &'a [BTreeMap<&'a str, usize>],
pub source_index: usize,
pub manifest: &'a Built,
}
pub fn corpus(corpus: &Corpus<'_>, config: &Config, features: &Features, report: &mut Report) {
for (locale, source) in corpus.sources.iter().enumerate() {
let mut sink = Sink::new(report, &source.tag);
for (record_index, record) in source.loaded.records.iter().enumerate() {
let Some(model) = corpus.models[locale]
.get(record_index)
.and_then(Option::as_ref)
else {
continue;
};
let analysis = mf2_syntax::analyze(model);
let mut at = At {
sink: &mut sink,
source,
record,
config,
};
functions(&mut at, model, &analysis, features);
options(&mut at, model);
markup(&mut at, model, &analysis);
bidi(&mut at, model);
normalization(&mut at, model);
names(&mut at, model, &analysis);
plural_categories(&mut at, model, &source.tag);
if locale != corpus.source_index {
against_source(&mut at, corpus, model, &analysis);
}
}
coverage(&mut sink, corpus, locale, config);
}
}
struct At<'r, 'c> {
sink: &'r mut Sink<'c>,
source: &'r LocaleSource,
record: &'r Record,
config: &'r Config,
}
impl At<'_, '_> {
fn say(&mut self, lint: Lint, offset: u32, message: impl Into<String>) {
let level = self.config.level(lint);
if level == Level::Allow {
return;
}
let position = self.source.position(self.record, offset, offset);
let path = self.source.file(self.record).path.clone();
self.sink.add(
level,
Some(lint),
&path,
position,
Some(&self.record.id),
message,
);
}
fn offset_of(&self, part: &str) -> u32 {
self.record
.source
.find(part)
.and_then(|at| u32::try_from(at).ok())
.unwrap_or(0)
}
}
fn functions(
at: &mut At<'_, '_>,
model: &Message<'_>,
analysis: &Analysis<'_>,
features: &Features,
) {
for name in &analysis.functions {
let offset = at.offset_of(name.spelling);
match features.provides(&name.nfc) {
Some(true) => {}
Some(false) => {
let feature = features
.missing_feature(&name.nfc)
.unwrap_or("the right feature");
at.say(
Lint::GatedFunction,
offset,
format!(
":{} needs the `{feature}` feature, which this build does \
not have; a message may never add formatting code to the \
wasm by itself",
name.nfc
),
);
}
None => {
if !at.config.functions.contains_key(name.nfc.as_ref()) {
at.say(
Lint::UnknownFunction,
offset,
format!(
"no crate provides :{}; a custom function has to be \
named under [functions] in mf2.toml",
name.nfc
),
);
}
}
}
}
let _ = model;
}
fn options(at: &mut At<'_, '_>, model: &Message<'_>) {
let mut check = |function: Option<&FunctionRef<'_>>| {
let Some(function) = function else { return };
for (name, value) in function.options.iter() {
if defines_option(&function.name, name) == Some(false) {
at.say(
Lint::UnknownOption,
at.offset_of(name),
format!(
":{} has no option {name}; it is ignored, and the message \
formats as if it were not there",
function.name
),
);
}
let literal = match value {
OptionValue::Literal(literal) => Some(literal.value.as_ref()),
_ => None,
};
match (name, literal) {
("select", Some(v)) if !matches!(v, "plural" | "ordinal" | "exact") => at.say(
Lint::BadOptionValue,
at.offset_of(v),
format!("select={v}: expected plural, ordinal or exact"),
),
("select", None) => at.say(
Lint::DynamicSelect,
at.offset_of(name.as_ref()),
"select must be a literal: the build decides from it which \
plural rules a catalog carries",
),
("currency", Some(v)) if !is_currency_code(v) => at.say(
Lint::BadOptionValue,
at.offset_of(v),
format!("currency={v}: expected a three-letter ISO 4217 code"),
),
("unit", Some(v)) if !is_unit_id(v) => at.say(
Lint::BadOptionValue,
at.offset_of(v),
format!("unit={v}: not a CLDR unit identifier"),
),
(
"minimumIntegerDigits"
| "minimumFractionDigits"
| "maximumFractionDigits"
| "minimumSignificantDigits"
| "maximumSignificantDigits",
Some(v),
) if v.parse::<u32>().is_err() => at.say(
Lint::BadOptionValue,
at.offset_of(v),
format!("{name}={v}: expected a whole number"),
),
_ => {}
}
}
};
for declaration in model.declarations() {
match declaration {
Declaration::Input(input) => check(input.value.function.as_ref()),
Declaration::Local(local) => check(local.value.function()),
_ => {}
}
}
for pattern in patterns(model) {
for part in pattern {
if let PatternPart::Expression(e) = part {
check(e.function());
}
}
}
}
fn is_currency_code(code: &str) -> bool {
code.len() == 3 && code.bytes().all(|b| b.is_ascii_alphabetic())
}
fn is_unit_id(id: &str) -> bool {
mf2_locale_data::unit_ids().is_ok_and(|ids| ids.contains(&id))
|| mf2_locale_data::composition(id).is_ok_and(|c| c.is_some())
}
fn markup(at: &mut At<'_, '_>, model: &Message<'_>, analysis: &Analysis<'_>) {
for pattern in patterns(model) {
let mut open: Vec<&str> = Vec::new();
let mut unmatched_close: Vec<&str> = Vec::new();
for part in pattern {
let PatternPart::Markup(m) = part else {
continue;
};
match m.kind {
mf2_model::MarkupKind::Open => open.push(m.name.as_ref()),
mf2_model::MarkupKind::Close => {
if let Some(i) = open.iter().rposition(|n| *n == m.name.as_ref()) {
open.remove(i);
} else {
unmatched_close.push(m.name.as_ref());
}
}
mf2_model::MarkupKind::Standalone => {}
}
}
for name in open {
at.say(
Lint::UnpairedMarkup,
at.offset_of(name),
format!("{{#{name}}} is opened and never closed"),
);
}
for name in unmatched_close {
at.say(
Lint::UnpairedMarkup,
at.offset_of(name),
format!("{{/{name}}} closes markup that was never opened"),
);
}
}
let _ = analysis;
}
fn bidi(at: &mut At<'_, '_>, model: &Message<'_>) {
for pattern in patterns(model) {
let mut depth = 0i32;
let mut text = None;
for part in pattern {
let PatternPart::Text(run) = part else {
continue;
};
text.get_or_insert(run.as_ref());
for c in run.chars() {
match c {
'\u{2066}'..='\u{2068}' => depth += 1,
'\u{2069}' => depth -= 1,
_ => {}
}
}
}
if depth != 0 {
let offset = text.map_or(0, |t| at.offset_of(t));
at.say(
Lint::SuspiciousBidi,
offset,
if depth > 0 {
"an isolate is opened and never closed (U+2069 missing)"
} else {
"an isolate is closed that was never opened"
},
);
}
}
}
fn normalization(at: &mut At<'_, '_>, model: &Message<'_>) {
for pattern in patterns(model) {
for part in pattern {
let PatternPart::Text(text) = part else {
continue;
};
if non_nfc(text) {
at.say(
Lint::NonNfcSource,
at.offset_of(text),
"this text is not in Unicode Normalization Form C",
);
return;
}
}
}
}
fn names(at: &mut At<'_, '_>, model: &Message<'_>, analysis: &Analysis<'_>) {
let mut found: Vec<(&'static str, &str)> = Vec::new();
for name in analysis.externals.iter().chain(&analysis.locals) {
found.push(("variable", &name.nfc));
}
for name in &analysis.functions {
found.push(("function", &name.nfc));
}
for name in &analysis.markup {
found.push(("markup", &name.nfc));
}
for declaration in model.declarations() {
match declaration {
Declaration::Input(input) => add(
&mut found,
input.value.function.as_ref().map(|f| &f.options),
&input.value.attributes,
),
Declaration::Local(local) => add(
&mut found,
local.value.function().map(|f| &f.options),
local.value.attributes(),
),
_ => {}
}
}
for pattern in patterns(model) {
for part in pattern {
match part {
PatternPart::Expression(e) => {
add(&mut found, e.function().map(|f| &f.options), e.attributes());
}
PatternPart::Markup(m) => add(&mut found, Some(&m.options), &m.attributes),
_ => {}
}
}
}
let mut seen: BTreeSet<String> = BTreeSet::new();
for (kind, name) in found {
let name = nfc(name);
let Some(why) = name.split(':').find_map(nonstandard) else {
continue;
};
if seen.insert(name.to_string()) {
at.say(
Lint::NonstandardName,
at.offset_of(&name),
format!("the {kind} name `{name}` {why}"),
);
}
}
}
fn add<'m>(
found: &mut Vec<(&'static str, &'m str)>,
options: Option<&'m Options<'_>>,
attributes: &'m Attributes<'_>,
) {
for (name, _) in options.into_iter().flat_map(Options::iter) {
found.push(("option", name));
}
for (name, _) in attributes.iter() {
found.push(("attribute", name));
}
}
fn nonstandard(name: &str) -> Option<&'static str> {
use unicode_security::{GeneralSecurityProfile, MixedScript};
let mut chars = name.chars();
let start = chars.next()?;
let identifier = (unicode_ident::is_xid_start(start) || start == '_')
&& chars.all(|c| unicode_ident::is_xid_continue(c) || c == '-' || c == '.');
if !identifier {
Some("is not a Unicode identifier (UAX #31)")
} else if !name.chars().all(GeneralSecurityProfile::identifier_allowed) {
Some("uses a character the General Security Profile does not allow (UTS #39)")
} else if !name.is_single_script() {
Some("mixes scripts (UTS #39)")
} else {
None
}
}
fn nfc(name: &str) -> std::borrow::Cow<'_, str> {
use unicode_normalization::UnicodeNormalization;
if non_nfc(name) {
std::borrow::Cow::Owned(name.nfc().collect())
} else {
std::borrow::Cow::Borrowed(name)
}
}
fn plural_categories(at: &mut At<'_, '_>, model: &Message<'_>, locale: &str) {
let Message::Select(select) = model else {
return;
};
let kinds = plural_kinds_used(select);
if kinds.is_empty() {
return;
}
let mut keys: BTreeSet<&str> = BTreeSet::new();
for variant in &select.variants {
for key in &variant.keys {
if let Key::Literal(literal) = key {
keys.insert(literal.value.as_ref());
}
}
}
for kind in kinds {
let Ok(rules) = plural_rules(kind, locale) else {
continue;
};
let missing: Vec<&str> = rules
.rules
.iter()
.map(|r| r.category.as_str())
.filter(|c| *c != "other" && !keys.contains(c))
.collect();
if !missing.is_empty() {
at.say(
Lint::MissingPluralCategory,
0,
format!(
"{locale} has the {} categor{} {}, which no variant names; \
they all fall to the catch-all",
if kind == PluralKind::Ordinal {
"ordinal"
} else {
"plural"
},
if missing.len() == 1 { "y" } else { "ies" },
missing.join(", ")
),
);
}
}
}
fn plural_kinds_used(select: &mf2_model::SelectMessage<'_>) -> Vec<PluralKind> {
let (cardinal, ordinal) = crate::slice::selector_kinds(select);
let mut kinds = Vec::new();
if cardinal {
kinds.push(PluralKind::Cardinal);
}
if ordinal {
kinds.push(PluralKind::Ordinal);
}
kinds
}
fn against_source(
at: &mut At<'_, '_>,
corpus: &Corpus<'_>,
model: &Message<'_>,
analysis: &Analysis<'_>,
) {
let id = at.record.id.as_str();
let source_locale = &corpus.sources[corpus.source_index];
let Some(&record) = corpus.indexes[corpus.source_index].get(id) else {
return;
};
let Some(source_model) = corpus.models[corpus.source_index]
.get(record)
.and_then(Option::as_ref)
else {
return;
};
let source_analysis = mf2_syntax::analyze(source_model);
let source_vars: BTreeSet<&str> = source_analysis
.externals
.iter()
.map(|n| n.nfc.as_ref())
.collect();
let source_markup: BTreeSet<&str> = source_analysis
.markup
.iter()
.map(|n| n.nfc.as_ref())
.collect();
for name in &analysis.externals {
if !source_vars.contains(name.nfc.as_ref()) {
at.say(
Lint::UndeclaredVariable,
at.offset_of(name.spelling),
format!(
"${} is not an input of the source message; if this language \
needs it, the source has to declare it with .input",
name.nfc
),
);
}
}
for name in &analysis.markup {
if !source_markup.contains(name.nfc.as_ref()) {
at.say(
Lint::UndeclaredMarkup,
at.offset_of(name.spelling),
format!("{{#{}}} is not in the source message", name.nfc),
);
}
}
let here: BTreeSet<&str> = analysis.externals.iter().map(|n| n.nfc.as_ref()).collect();
let dropped: Vec<&str> = source_vars.difference(&here).copied().collect();
if !dropped.is_empty() {
at.say(
Lint::DroppedPlaceholder,
0,
format!(
"the source message shows {}, which this translation does not",
dropped
.iter()
.map(|v| format!("${v}"))
.collect::<Vec<_>>()
.join(", ")
),
);
}
let here: BTreeSet<&str> = analysis.markup.iter().map(|n| n.nfc.as_ref()).collect();
let dropped: Vec<&str> = source_markup.difference(&here).copied().collect();
if !dropped.is_empty() {
at.say(
Lint::DroppedMarkup,
0,
format!(
"the source message has {}, which this translation leaves out, so this \
language loses what it marks (a link, a style)",
dropped
.iter()
.map(|m| format!("{{#{m}}}"))
.collect::<Vec<_>>()
.join(", ")
),
);
}
let source_record = &source_locale.loaded.records[record];
if source_record.do_not_translate() && source_record.source != at.record.source {
at.say(
Lint::DoNotTranslate,
0,
format!(
"this message is marked @do-not-translate in {}, but differs here",
source_locale.tag
),
);
}
let _ = model;
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Coverage {
pub tag: String,
pub translatable: usize,
pub missing: Vec<String>,
}
impl Coverage {
pub fn translated(&self) -> usize {
self.translatable - self.missing.len()
}
}
pub fn coverage_of(corpus: &Corpus<'_>, locale: usize) -> Coverage {
let source = &corpus.sources[corpus.source_index];
let needs_translating = |id: &&String| {
corpus.indexes[corpus.source_index]
.get(id.as_str())
.is_none_or(|&record| !source.loaded.records[record].do_not_translate())
};
let ids: Vec<&String> = corpus
.manifest
.manifest
.ids
.iter()
.filter(needs_translating)
.collect();
Coverage {
tag: corpus.sources[locale].tag.clone(),
translatable: ids.len(),
missing: ids
.into_iter()
.filter(|id| !corpus.indexes[locale].contains_key(id.as_str()))
.cloned()
.collect(),
}
}
fn coverage(sink: &mut Sink<'_>, corpus: &Corpus<'_>, locale: usize, config: &Config) {
if locale == corpus.source_index {
return;
}
let source = &corpus.sources[locale];
let coverage = coverage_of(corpus, locale);
let ids: Vec<&str> = coverage.missing.iter().map(String::as_str).collect();
let missing = ids.len();
if missing == 0 {
return;
}
let level = config.level(Lint::MissingTranslation);
if level == Level::Allow {
return;
}
let total = coverage.translatable;
let path = source
.loaded
.files
.first()
.map_or_else(|| source.path.clone(), |f| f.path.clone());
sink.add(
level,
Some(Lint::MissingTranslation),
&path,
mf2_resource::Position { line: 1, column: 1 },
None,
match config.catalog.missing {
Missing::Fallback => format!(
"{missing} of {total} messages are missing here and fall back to {}: {}",
config.chain(&source.tag).join(", "),
first_ids(&ids)
),
Missing::Empty => format!(
"{missing} of {total} messages are missing here and ship empty \
(`[catalog] missing = \"empty\"`): {}",
first_ids(&ids)
),
Missing::Id => format!(
"{missing} of {total} messages are missing here and ship as their id \
(`[catalog] missing = \"id\"`): {}",
first_ids(&ids)
),
},
);
}
const IDS_SHOWN: usize = 10;
fn first_ids(ids: &[&str]) -> String {
let shown = ids.len().min(IDS_SHOWN);
let list = ids[..shown].join(", ");
match ids.len() - shown {
0 => list,
more => format!("{list}, and {more} more"),
}
}
pub fn unused_ids(
ids: &[String],
sources: &str,
locale: &str,
defined: &BTreeMap<String, (std::path::PathBuf, mf2_resource::Position)>,
where_looked: &std::path::Path,
config: &Config,
report: &mut Report,
) {
let level = config.level(Lint::UnusedId);
if level == Level::Allow {
return;
}
let mut unused: Vec<&str> = ids
.iter()
.filter(|id| !sources.contains(id.as_str()))
.map(String::as_str)
.collect();
unused.sort_unstable();
let mut sink = Sink::new(report, locale);
for id in unused {
let (file, at) = defined.get(id).map_or(
(where_looked, mf2_resource::Position { line: 1, column: 1 }),
|(file, at)| (file.as_path(), *at),
);
sink.add(
level,
Some(Lint::UnusedId),
file,
at,
Some(id),
"no source file names this id".to_owned(),
);
}
}
fn patterns<'m>(message: &'m Message<'_>) -> Vec<&'m Pattern<'m>> {
match message {
Message::Pattern(p) => vec![&p.pattern],
Message::Select(s) => s.variants.iter().map(|v| &v.value).collect(),
_ => Vec::new(),
}
}
pub fn non_nfc(source: &str) -> bool {
use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
match is_nfc_quick(source.chars()) {
IsNormalized::Yes => false,
IsNormalized::No => true,
IsNormalized::Maybe => source.nfc().ne(source.chars()),
}
}
#[cfg(test)]
mod tests {
use super::nonstandard;
#[test]
fn mf2_names_are_standard_under_the_profile() {
for name in [
"name",
"_x",
"a-b.c",
"x1",
"número",
"名前",
"ひらがな漢字",
"имя",
] {
assert_eq!(nonstandard(name), None, "{name}");
}
}
#[test]
fn each_reason_is_reported() {
assert_eq!(
nonstandard("\u{2140}x"),
Some("is not a Unicode identifier (UAX #31)")
);
assert_eq!(
nonstandard("\u{1c5}x"),
Some("uses a character the General Security Profile does not allow (UTS #39)")
);
assert_eq!(nonstandard("n\u{430}me"), Some("mixes scripts (UTS #39)"));
}
}