Skip to main content

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}