Skip to main content

ridl_core/
lint.rs

1//! The lint registry: lint names and levels (ADR-0024 decisions 1 and 4).
2//!
3//! Every Warning and Info row of the diagnostic catalogue carries a lint name
4//! ([`CatalogEntry::lint`]). The catalogue is the registry; this module looks
5//! names up in it, defines the four levels a `[lints]` table can set, resolves
6//! the effective levels by directory ([`LintScopes`], ADR-0024 decision 10),
7//! and applies them to a diagnostic list ([`apply_lint_levels`], ADR-0024
8//! decision 6).
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use crate::diag::{ALL_CATALOGS, CatalogEntry, DiagCode, Diagnostic, Severity, SourceMap};
14
15/// The level a project sets for a lint. The order is from the least to the
16/// most severe.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub enum LintLevel {
19    /// The diagnostic is dropped.
20    Allow,
21    /// The diagnostic is reported as Info.
22    Info,
23    /// The diagnostic is reported as a Warning.
24    Warn,
25    /// The diagnostic is reported as an Error.
26    Deny,
27}
28
29impl LintLevel {
30    /// Parses a level string. Only the exact lowercase strings `allow`,
31    /// `info`, `warn` and `deny` are accepted.
32    pub fn parse(text: &str) -> Option<LintLevel> {
33        match text {
34            "allow" => Some(LintLevel::Allow),
35            "info" => Some(LintLevel::Info),
36            "warn" => Some(LintLevel::Warn),
37            "deny" => Some(LintLevel::Deny),
38            _ => None,
39        }
40    }
41
42    /// The level string, as written in a `[lints]` table.
43    pub fn as_str(self) -> &'static str {
44        match self {
45            LintLevel::Allow => "allow",
46            LintLevel::Info => "info",
47            LintLevel::Warn => "warn",
48            LintLevel::Deny => "deny",
49        }
50    }
51
52    /// The severity a diagnostic at this level is reported with, or `None`
53    /// for `Allow`, which drops the diagnostic.
54    pub fn severity(self) -> Option<Severity> {
55        match self {
56            LintLevel::Allow => None,
57            LintLevel::Info => Some(Severity::Info),
58            LintLevel::Warn => Some(Severity::Warning),
59            LintLevel::Deny => Some(Severity::Error),
60        }
61    }
62}
63
64/// Every catalogue row that has a lint name.
65fn lint_entries() -> impl Iterator<Item = &'static CatalogEntry> {
66    ALL_CATALOGS
67        .iter()
68        .flat_map(|(_, catalog)| catalog.iter())
69        .filter(|entry| entry.lint.is_some())
70}
71
72/// The catalogue row whose lint name is `name`. A diagnostic code such as
73/// `RIDL-100` is not a lint name and returns `None`.
74pub fn lint_by_name(name: &str) -> Option<&'static CatalogEntry> {
75    lint_entries().find(|entry| entry.lint == Some(name))
76}
77
78/// The catalogue row of `code`, when that row has a lint name. An Error code
79/// has none and returns `None`.
80pub fn lint_of(code: DiagCode) -> Option<&'static CatalogEntry> {
81    lint_entries().find(|entry| entry.code == code)
82}
83
84/// The default level of a lint: `Allow` when its row declares
85/// `default = allow` (ADR-0024 decision 1), and otherwise its catalogue
86/// severity, Warning as `Warn` and Info as `Info`. No lint defaults to
87/// `Deny`. An Error row is not a lint and has no level, so it returns `None`
88/// (ADR-0024 decision 15).
89pub fn default_level(entry: &CatalogEntry) -> Option<LintLevel> {
90    match entry.severity {
91        Severity::Error => None,
92        _ if entry.allow_by_default => Some(LintLevel::Allow),
93        Severity::Info => Some(LintLevel::Info),
94        Severity::Warning => Some(LintLevel::Warn),
95    }
96}
97
98/// One `[lints]` table: lint name to level. The keys are registered lint
99/// names, the `lint` of a catalogue row; the manifest parser only inserts
100/// names it found in the registry.
101pub type LintTable = BTreeMap<&'static str, LintLevel>;
102
103/// The effective levels of one package: the registry defaults with the
104/// overrides of every `[lints]` table that applies, the later table winning
105/// (ADR-0002 ยง4).
106#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct LintLevels {
108    overrides: LintTable,
109}
110
111impl LintLevels {
112    /// Applies `table` over the overrides held so far. A key present in
113    /// both takes the level from `table`.
114    pub fn overlay(&mut self, table: &LintTable) {
115        for (name, level) in table {
116            self.overrides.insert(name, *level);
117        }
118    }
119
120    /// The level of the lint `entry` names: its override, or its default.
121    /// `None` when `entry` is not a lint (an Error row).
122    pub fn level(&self, entry: &CatalogEntry) -> Option<LintLevel> {
123        entry
124            .lint
125            .and_then(|name| self.overrides.get(name).copied())
126            .or_else(|| default_level(entry))
127    }
128}
129
130/// The effective levels of every directory the loader resolved: the workspace
131/// root, each member, or a standalone package (ADR-0024 decision 10). A file is
132/// looked up by the longest directory that is a prefix of its path.
133#[derive(Debug, Clone, Default)]
134pub struct LintScopes {
135    scopes: Vec<(PathBuf, LintLevels)>,
136}
137
138impl LintScopes {
139    /// Records `levels` as the effective levels of `dir` and every path under
140    /// `dir`, until a longer scope takes over.
141    pub fn insert(&mut self, dir: PathBuf, levels: LintLevels) {
142        self.scopes.push((dir, levels));
143    }
144
145    /// The levels of the longest scope directory that is a prefix of `path`,
146    /// compared component by component (`/ws/a` is not a prefix of
147    /// `/ws/ab/x.ridl`), or `None` when no scope contains `path`.
148    pub fn for_path(&self, path: &Path) -> Option<&LintLevels> {
149        self.scopes
150            .iter()
151            .filter(|(dir, _)| path.starts_with(dir))
152            .max_by_key(|(dir, _)| dir.components().count())
153            .map(|(_, levels)| levels)
154    }
155}
156
157/// Applies the effective lint levels to `diagnostics` (ADR-0024 decision 6).
158///
159/// For each diagnostic whose code is a lint, the level comes from the scope of
160/// `sources.path(primary.file)`, or from the registry defaults when the file
161/// has no path (a detached diagnostic, or an id `sources` never issued) or its
162/// path is in no scope. `allow` removes the diagnostic; `info`, `warn` and
163/// `deny` set its severity. A diagnostic with an Error code or with no code is
164/// left unchanged. Applying the function twice gives the same list.
165pub fn apply_lint_levels(
166    diagnostics: &mut Vec<Diagnostic>,
167    sources: &SourceMap,
168    scopes: &LintScopes,
169) {
170    let defaults = LintLevels::default();
171    diagnostics.retain_mut(|diagnostic| {
172        let Some(entry) = lint_of(diagnostic.code) else {
173            return true;
174        };
175        let levels = sources
176            .path(diagnostic.primary.file)
177            .and_then(|path| scopes.for_path(Path::new(path)))
178            .unwrap_or(&defaults);
179        let level = levels
180            .level(entry)
181            .expect("`lint_of` returns a lint row, and every lint row has a level");
182        match level.severity() {
183            Some(severity) => {
184                diagnostic.severity = severity;
185                true
186            }
187            None => false,
188        }
189    });
190}
191
192/// Removes every diagnostic whose lint is `allow` by default (ADR-0024
193/// decision 1) and leaves every other diagnostic unchanged.
194///
195/// A path that applies no `[lints]` levels (ADR-0024 decision 8) reports the
196/// severities the emit sites chose. An emit site gives an allow-by-default
197/// lint a Warning severity, which is not its default level, so such a path
198/// calls this function to keep that lint silent as its default requires.
199pub fn drop_allowed_by_default(diagnostics: &mut Vec<Diagnostic>) {
200    diagnostics
201        .retain(|diagnostic| !lint_of(diagnostic.code).is_some_and(|entry| entry.allow_by_default));
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::diag::{FileId, Span};
208    use rowan::{TextRange, TextSize};
209
210    #[test]
211    fn parse_accepts_exactly_the_four_lowercase_levels() {
212        for level in [
213            LintLevel::Allow,
214            LintLevel::Info,
215            LintLevel::Warn,
216            LintLevel::Deny,
217        ] {
218            assert_eq!(LintLevel::parse(level.as_str()), Some(level));
219        }
220        assert_eq!(LintLevel::parse("allow"), Some(LintLevel::Allow));
221        assert_eq!(LintLevel::parse("info"), Some(LintLevel::Info));
222        assert_eq!(LintLevel::parse("warn"), Some(LintLevel::Warn));
223        assert_eq!(LintLevel::parse("deny"), Some(LintLevel::Deny));
224        assert_eq!(LintLevel::parse("Deny"), None);
225        assert_eq!(LintLevel::parse(""), None);
226        assert_eq!(LintLevel::parse("forbid"), None);
227    }
228
229    #[test]
230    fn severity_maps_each_level_and_allow_drops() {
231        assert_eq!(LintLevel::Allow.severity(), None);
232        assert_eq!(LintLevel::Info.severity(), Some(Severity::Info));
233        assert_eq!(LintLevel::Warn.severity(), Some(Severity::Warning));
234        assert_eq!(LintLevel::Deny.severity(), Some(Severity::Error));
235    }
236
237    #[test]
238    fn lint_by_name_finds_the_row_and_rejects_a_code() {
239        let entry = lint_by_name("missing-timing").expect("missing-timing is a lint");
240        assert_eq!(entry.code, DiagCode::RIDL_100);
241        assert!(lint_by_name("RIDL-100").is_none());
242    }
243
244    #[test]
245    fn lint_of_an_error_code_is_none() {
246        assert!(lint_of(DiagCode::RIDL_101).is_none());
247        assert_eq!(
248            lint_of(DiagCode::RIDL_100).and_then(|entry| entry.lint),
249            Some("missing-timing"),
250        );
251    }
252
253    #[test]
254    fn default_level_follows_the_catalogue_severity() {
255        let info = lint_of(DiagCode::RIDL_405).expect("RIDL-405 is a lint");
256        assert_eq!(default_level(info), Some(LintLevel::Info));
257        let warn = lint_of(DiagCode::RIDL_100).expect("RIDL-100 is a lint");
258        assert_eq!(default_level(warn), Some(LintLevel::Warn));
259    }
260
261    #[test]
262    fn a_row_declared_allow_defaults_to_allow() {
263        let style = lint_by_name("doc-comment-style").expect("doc-comment-style is a lint");
264        assert_eq!(default_level(style), Some(LintLevel::Allow));
265        assert_eq!(LintLevels::default().level(style), Some(LintLevel::Allow));
266        let detached = lint_of(DiagCode::TYPL_404).expect("TYPL-404 is a lint");
267        assert_eq!(default_level(detached), Some(LintLevel::Warn));
268    }
269
270    #[test]
271    fn drop_allowed_by_default_removes_only_allow_by_default_lints() {
272        let diagnostic = |code: DiagCode, severity: Severity| Diagnostic {
273            code,
274            severity,
275            message: String::new(),
276            primary: Span {
277                file: FileId::DETACHED,
278                range: TextRange::empty(TextSize::from(0)),
279            },
280            labels: Vec::new(),
281            fixits: Vec::new(),
282        };
283        let mut diagnostics = vec![
284            diagnostic(DiagCode::TYPL_410, Severity::Warning),
285            diagnostic(DiagCode::TYPL_404, Severity::Warning),
286            diagnostic(DiagCode::RIDL_101, Severity::Error),
287        ];
288        drop_allowed_by_default(&mut diagnostics);
289        let codes: Vec<&str> = diagnostics.iter().map(|d| d.code.as_str()).collect();
290        assert_eq!(codes, ["TYPL-404", "RIDL-101"]);
291    }
292
293    /// The row of an Error code, which has no lint name.
294    fn error_row() -> &'static CatalogEntry {
295        ALL_CATALOGS
296            .iter()
297            .flat_map(|(_, catalog)| catalog.iter())
298            .find(|entry| entry.code == DiagCode::RIDL_101)
299            .expect("RIDL-101 is in the catalogue")
300    }
301
302    #[test]
303    fn an_error_row_has_no_default_level_and_no_level() {
304        let error = error_row();
305        assert_eq!(error.severity, Severity::Error);
306        assert_eq!(default_level(error), None);
307        assert_eq!(LintLevels::default().level(error), None);
308    }
309
310    /// `LintLevels` with one override, built the way the manifest parser does.
311    fn levels(name: &'static str, level: LintLevel) -> LintLevels {
312        let mut levels = LintLevels::default();
313        levels.overlay(&LintTable::from([(name, level)]));
314        levels
315    }
316
317    #[test]
318    fn overlay_later_table_wins() {
319        let mut levels = LintLevels::default();
320        levels.overlay(&LintTable::from([("missing-timing", LintLevel::Deny)]));
321        levels.overlay(&LintTable::from([("missing-timing", LintLevel::Allow)]));
322        let entry = lint_by_name("missing-timing").expect("missing-timing is a lint");
323        assert_eq!(levels.level(entry), Some(LintLevel::Allow));
324    }
325
326    #[test]
327    fn level_falls_back_to_the_default() {
328        let levels = levels("missing-timing", LintLevel::Deny);
329        let other = lint_of(DiagCode::RIDL_405).expect("RIDL-405 is a lint");
330        assert_eq!(levels.level(other), default_level(other));
331    }
332
333    #[test]
334    fn longest_scope_wins() {
335        let entry = lint_by_name("missing-timing").expect("missing-timing is a lint");
336        let mut scopes = LintScopes::default();
337        scopes.insert(
338            PathBuf::from("/ws"),
339            levels("missing-timing", LintLevel::Warn),
340        );
341        scopes.insert(
342            PathBuf::from("/ws/a"),
343            levels("missing-timing", LintLevel::Deny),
344        );
345        let level = |path: &str| {
346            scopes
347                .for_path(Path::new(path))
348                .and_then(|l| l.level(entry))
349        };
350        assert_eq!(level("/ws/a/x.ridl"), Some(LintLevel::Deny));
351        assert_eq!(level("/ws/b/x.ridl"), Some(LintLevel::Warn));
352        assert_eq!(level("/other/x.ridl"), None);
353    }
354
355    #[test]
356    fn longest_scope_wins_whatever_the_insertion_order() {
357        let entry = lint_by_name("missing-timing").expect("missing-timing is a lint");
358        let mut scopes = LintScopes::default();
359        scopes.insert(
360            PathBuf::from("/ws/a"),
361            levels("missing-timing", LintLevel::Deny),
362        );
363        scopes.insert(
364            PathBuf::from("/ws"),
365            levels("missing-timing", LintLevel::Warn),
366        );
367        let found = scopes
368            .for_path(Path::new("/ws/a/x.ridl"))
369            .expect("in scope");
370        assert_eq!(found.level(entry), Some(LintLevel::Deny));
371    }
372
373    #[test]
374    fn scope_match_is_by_component() {
375        let mut scopes = LintScopes::default();
376        scopes.insert(
377            PathBuf::from("/ws/a"),
378            levels("missing-timing", LintLevel::Deny),
379        );
380        assert!(scopes.for_path(Path::new("/ws/ab/x.ridl")).is_none());
381        assert!(scopes.for_path(Path::new("/ws/a/x.ridl")).is_some());
382    }
383
384    /// A diagnostic of `code` at `severity`, with its primary span in `file`.
385    fn diagnostic(code: DiagCode, severity: Severity, file: FileId) -> Diagnostic {
386        Diagnostic {
387            code,
388            severity,
389            message: code.as_str().to_string(),
390            primary: Span {
391                file,
392                range: TextRange::new(TextSize::from(0), TextSize::from(1)),
393            },
394            labels: Vec::new(),
395            fixits: Vec::new(),
396        }
397    }
398
399    /// One scope for `/ws` with `missing-timing` at Deny and
400    /// `shared-error-type` (RIDL-405) at Allow.
401    fn deny_and_allow_scopes() -> LintScopes {
402        let allow_name = lint_of(DiagCode::RIDL_405)
403            .and_then(|entry| entry.lint)
404            .expect("RIDL-405 is a lint");
405        let mut levels = LintLevels::default();
406        levels.overlay(&LintTable::from([
407            ("missing-timing", LintLevel::Deny),
408            (allow_name, LintLevel::Allow),
409        ]));
410        let mut scopes = LintScopes::default();
411        scopes.insert(PathBuf::from("/ws"), levels);
412        scopes
413    }
414
415    #[test]
416    fn apply_rewrites_and_removes() {
417        let mut sources = SourceMap::new();
418        let file = sources.file_id("/ws/x.ridl", "interface X {}");
419        let scopes = deny_and_allow_scopes();
420        let mut diagnostics = vec![
421            diagnostic(DiagCode::RIDL_100, Severity::Warning, file),
422            diagnostic(DiagCode::RIDL_405, Severity::Info, file),
423            diagnostic(DiagCode::RIDL_101, Severity::Error, file),
424            diagnostic(DiagCode::NONE, Severity::Warning, file),
425        ];
426        apply_lint_levels(&mut diagnostics, &sources, &scopes);
427        let kept: Vec<(DiagCode, Severity)> =
428            diagnostics.iter().map(|d| (d.code, d.severity)).collect();
429        assert_eq!(
430            kept,
431            vec![
432                (DiagCode::RIDL_100, Severity::Error),
433                (DiagCode::RIDL_101, Severity::Error),
434                (DiagCode::NONE, Severity::Warning),
435            ],
436        );
437    }
438
439    /// The two depth lints of the rsdl checker, RSDL-805 and RSDL-806, take
440    /// their level from the `[lints]` table like every other warning row.
441    #[test]
442    fn the_depth_lints_take_their_level_from_the_table() {
443        let mut sources = SourceMap::new();
444        let file = sources.file_id("/ws/x.rsdl", "deployment P for S {}");
445        let mut levels = LintLevels::default();
446        levels.overlay(&LintTable::from([
447            ("depth-below-bound", LintLevel::Allow),
448            ("depth-underivable", LintLevel::Deny),
449        ]));
450        let mut scopes = LintScopes::default();
451        scopes.insert(PathBuf::from("/ws"), levels);
452        let mut diagnostics = vec![
453            diagnostic(DiagCode::RSDL_805, Severity::Warning, file),
454            diagnostic(DiagCode::RSDL_806, Severity::Warning, file),
455        ];
456        apply_lint_levels(&mut diagnostics, &sources, &scopes);
457        let kept: Vec<(DiagCode, Severity)> =
458            diagnostics.iter().map(|d| (d.code, d.severity)).collect();
459        assert_eq!(kept, vec![(DiagCode::RSDL_806, Severity::Error)]);
460    }
461
462    #[test]
463    fn apply_uses_defaults_outside_scopes() {
464        let mut sources = SourceMap::new();
465        let outside = sources.file_id("/other/x.ridl", "interface X {}");
466        let scopes = deny_and_allow_scopes();
467        let mut diagnostics = vec![
468            diagnostic(DiagCode::RIDL_100, Severity::Error, outside),
469            diagnostic(DiagCode::RIDL_100, Severity::Error, FileId::DETACHED),
470        ];
471        apply_lint_levels(&mut diagnostics, &sources, &scopes);
472        assert_eq!(diagnostics.len(), 2);
473        assert!(
474            diagnostics.iter().all(|d| d.severity == Severity::Warning),
475            "outside every scope, and with no path, RIDL-100 is at its default: {diagnostics:?}",
476        );
477    }
478
479    #[test]
480    fn apply_is_idempotent() {
481        let mut sources = SourceMap::new();
482        let file = sources.file_id("/ws/x.ridl", "interface X {}");
483        let outside = sources.file_id("/other/x.ridl", "interface X {}");
484        let scopes = deny_and_allow_scopes();
485        let mut diagnostics = vec![
486            diagnostic(DiagCode::RIDL_100, Severity::Warning, file),
487            diagnostic(DiagCode::RIDL_405, Severity::Info, file),
488            diagnostic(DiagCode::RIDL_101, Severity::Error, file),
489            diagnostic(DiagCode::NONE, Severity::Warning, file),
490            diagnostic(DiagCode::RIDL_100, Severity::Error, outside),
491            diagnostic(DiagCode::RIDL_405, Severity::Error, outside),
492        ];
493        apply_lint_levels(&mut diagnostics, &sources, &scopes);
494        let once = diagnostics.clone();
495        apply_lint_levels(&mut diagnostics, &sources, &scopes);
496        assert_eq!(diagnostics, once);
497    }
498}