mf2_build/lint.rs
1//! The lints of `mf2 check` (`plans/05-tooling.md` §5): their names, their
2//! default levels, and how low `mf2.toml` may set each one.
3//!
4//! The checks themselves are in [`crate::check`]; this module is what
5//! [`Config`](crate::Config) parses `[lints]` against, so a name in a
6//! configuration and a name in a report are the same string.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12/// What a lint does when it fires.
13#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Deserialize, Serialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum Level {
16 /// Say nothing.
17 Allow,
18 /// Report it; the build goes on.
19 Warn,
20 /// Report it; the build fails.
21 Error,
22}
23
24impl fmt::Display for Level {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 f.write_str(match self {
27 Level::Allow => "allow",
28 Level::Warn => "warn",
29 Level::Error => "error",
30 })
31 }
32}
33
34macro_rules! lints {
35 ($(
36 $(#[$meta:meta])*
37 $variant:ident = ($name:literal, $default:ident, $floor:ident);
38 )*) => {
39 /// One check `mf2 check` makes.
40 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Deserialize, Serialize)]
41 #[serde(rename_all = "kebab-case")]
42 #[non_exhaustive]
43 pub enum Lint {
44 $( $(#[$meta])* $variant, )*
45 }
46
47 impl Lint {
48 /// Every lint, in declaration order.
49 pub const ALL: &'static [Lint] = &[$( Lint::$variant, )*];
50
51 /// The name `mf2.toml` and a report use.
52 pub fn name(self) -> &'static str {
53 match self { $( Lint::$variant => $name, )* }
54 }
55
56 /// What it does when `mf2.toml` says nothing.
57 pub fn default_level(self) -> Level {
58 match self { $( Lint::$variant => Level::$default, )* }
59 }
60
61 /// The lowest level `mf2.toml` may set it to. A lint whose floor
62 /// is [`Level::Error`] states a rule the rest of the pipeline
63 /// relies on — a build that ignored it could not produce a
64 /// correct catalog — so it cannot be turned down.
65 pub fn floor(self) -> Level {
66 match self { $( Lint::$variant => Level::$floor, )* }
67 }
68
69 /// The lint named `name`.
70 pub fn from_name(name: &str) -> Option<Lint> {
71 Lint::ALL.iter().copied().find(|l| l.name() == name)
72 }
73 }
74 };
75}
76
77lints! {
78 /// A translation has an id the source locale does not.
79 ExtraId = ("extra-id", Error, Error);
80 /// A translation uses a variable the source message does not declare.
81 /// A language that needs more input (grammatical gender, say) gets it by
82 /// the *source* declaring it with `.input`, even where its own pattern
83 /// ignores it.
84 UndeclaredVariable = ("undeclared-variable", Error, Error);
85 /// A translation uses a markup name the source message does not.
86 UndeclaredMarkup = ("undeclared-markup", Error, Error);
87 /// A translation leaves out markup the source message has: a
88 /// `{#link}terms{/link}` gone from the French sentence takes the link
89 /// away from French readers, and nothing at run time says so. An error by
90 /// default, where a dropped placeholder is a warning — a plural variant
91 /// routinely drops `{$count}` ("one" says "a message"), and nothing
92 /// routinely drops a link; a corpus that drops emphasis on purpose may
93 /// turn it down.
94 DroppedMarkup = ("dropped-markup", Error, Allow);
95 /// A `select` option is not a literal, so nothing can tell which rules a
96 /// message selects by until it runs. An error by default: the catalog
97 /// then has to carry both plural rule sets, and the message reports a Bad
98 /// Option anyway. A corpus that means it may turn it down.
99 DynamicSelect = ("dynamic-select", Error, Allow);
100 /// A well-known option has a literal value it cannot take. An error by
101 /// default, though the runtime would report Bad Option on its own — the
102 /// suite has messages that do exactly that on purpose.
103 BadOptionValue = ("bad-option-value", Error, Allow);
104 /// A function whose client feature is off — `:percent`, `:currency` and
105 /// `:unit` without `fn-number`, `:datetime`, `:date` and `:time` without
106 /// `fn-datetime`. A translation can never silently add formatting code to
107 /// the wasm (`plans/03-runtime.md` §5.1).
108 GatedFunction = ("gated-function", Error, Error);
109 /// An entry marked `@do-not-translate` differs from the source's.
110 DoNotTranslate = ("do-not-translate", Error, Allow);
111 /// Two entries of one locale have the same id, so one of them would be
112 /// silently dropped.
113 DuplicateId = ("duplicate-id", Error, Error);
114 /// A file declares an `@locale` other than the directory it sits in.
115 /// An error by default — it is nearly always a copy that was never
116 /// finished — but a corpus that keeps one locale's files under another
117 /// tag on purpose may turn it down.
118 LocaleMismatch = ("locale-mismatch", Error, Allow);
119 /// A function no registered crate provides. An error by default and
120 /// configurable, since custom functions are legal.
121 UnknownFunction = ("unknown-function", Error, Allow);
122
123 /// An id the source locale has and a translation does not: it falls back.
124 MissingTranslation = ("missing-translation", Warn, Allow);
125 /// A placeholder can receive a number and `fn-number` is off, so a number
126 /// renders without the locale's symbols. The build cannot see what an
127 /// application passes, so a placeholder that only receives text raises it too.
128 NeutralNumbers = ("neutral-numbers", Warn, Allow);
129 /// Markup opened and not closed, or closed and not opened.
130 UnpairedMarkup = ("unpaired-markup", Warn, Allow);
131 /// A plural `.match` that does not mention every category the *target*
132 /// locale has.
133 MissingPluralCategory = ("missing-plural-category", Warn, Allow);
134 /// Source text that is not in NFC.
135 NonNfcSource = ("non-nfc-source", Warn, Allow);
136 /// A placeholder the source has and a translation dropped.
137 DroppedPlaceholder = ("dropped-placeholder", Warn, Allow);
138 /// An option a built-in function does not define — `dateStyle` on
139 /// `:datetime`, say. MF2 ignores it, so the message formats as though it
140 /// were not there, and nothing at run time says so.
141 UnknownOption = ("unknown-option", Warn, Allow);
142 /// An id no `tr!` in the workspace names.
143 UnusedId = ("unused-id", Warn, Allow);
144 /// Unpaired bidi isolates in literal text.
145 SuspiciousBidi = ("suspicious-bidi", Warn, Allow);
146 /// A `:currency` whose `currency` option is not a literal, so the catalog
147 /// must carry every currency CLDR has; listing the codes it can hold in
148 /// `mf2.toml` `[locale_data]` carries only those and silences this
149 /// (`plans/02-catalog-format.md` §4.4).
150 DynamicCurrency = ("dynamic-currency", Warn, Allow);
151 /// The same for `:unit`.
152 DynamicUnit = ("dynamic-unit", Warn, Allow);
153 /// A variable, option, function, markup or attribute name that is not a
154 /// Unicode identifier under MF2's profile of UAX #31, uses a character
155 /// UTS #39's General Security Profile does not allow, or mixes scripts —
156 /// `syntax.md` asks linters to warn on exactly these.
157 NonstandardName = ("nonstandard-name", Warn, Allow);
158}
159
160impl fmt::Display for Lint {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 f.write_str(self.name())
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::{Level, Lint};
169
170 #[test]
171 fn names_are_unique_and_kebab_case() {
172 let mut names: Vec<&str> = Lint::ALL.iter().map(|l| l.name()).collect();
173 names.sort_unstable();
174 let mut unique = names.clone();
175 unique.dedup();
176 assert_eq!(names, unique, "two lints share a name");
177 for name in names {
178 assert!(
179 name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-')
180 && !name.starts_with('-')
181 && !name.ends_with('-'),
182 "{name} is not kebab-case"
183 );
184 assert_eq!(Lint::from_name(name).map(Lint::name), Some(name));
185 }
186 }
187
188 #[test]
189 fn a_default_is_never_below_its_floor() {
190 for &lint in Lint::ALL {
191 assert!(
192 lint.default_level() >= lint.floor(),
193 "{lint} defaults below its floor"
194 );
195 }
196 }
197
198 #[test]
199 fn a_lint_that_cannot_be_turned_down_is_an_error() {
200 for &lint in Lint::ALL {
201 if lint.floor() == Level::Error {
202 assert_eq!(lint.default_level(), Level::Error, "{lint}");
203 }
204 }
205 }
206}