1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use crate::diag::{ALL_CATALOGS, CatalogEntry, DiagCode, Diagnostic, Severity, SourceMap};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub enum LintLevel {
19 Allow,
21 Info,
23 Warn,
25 Deny,
27}
28
29impl LintLevel {
30 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 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 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
64fn 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
72pub fn lint_by_name(name: &str) -> Option<&'static CatalogEntry> {
75 lint_entries().find(|entry| entry.lint == Some(name))
76}
77
78pub fn lint_of(code: DiagCode) -> Option<&'static CatalogEntry> {
81 lint_entries().find(|entry| entry.code == code)
82}
83
84pub 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
98pub type LintTable = BTreeMap<&'static str, LintLevel>;
102
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct LintLevels {
108 overrides: LintTable,
109}
110
111impl LintLevels {
112 pub fn overlay(&mut self, table: &LintTable) {
115 for (name, level) in table {
116 self.overrides.insert(name, *level);
117 }
118 }
119
120 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#[derive(Debug, Clone, Default)]
134pub struct LintScopes {
135 scopes: Vec<(PathBuf, LintLevels)>,
136}
137
138impl LintScopes {
139 pub fn insert(&mut self, dir: PathBuf, levels: LintLevels) {
142 self.scopes.push((dir, levels));
143 }
144
145 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
157pub 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
192pub 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 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 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 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 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 #[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}