use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::diag::{ALL_CATALOGS, CatalogEntry, DiagCode, Diagnostic, Severity, SourceMap};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LintLevel {
Allow,
Info,
Warn,
Deny,
}
impl LintLevel {
pub fn parse(text: &str) -> Option<LintLevel> {
match text {
"allow" => Some(LintLevel::Allow),
"info" => Some(LintLevel::Info),
"warn" => Some(LintLevel::Warn),
"deny" => Some(LintLevel::Deny),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
LintLevel::Allow => "allow",
LintLevel::Info => "info",
LintLevel::Warn => "warn",
LintLevel::Deny => "deny",
}
}
pub fn severity(self) -> Option<Severity> {
match self {
LintLevel::Allow => None,
LintLevel::Info => Some(Severity::Info),
LintLevel::Warn => Some(Severity::Warning),
LintLevel::Deny => Some(Severity::Error),
}
}
}
fn lint_entries() -> impl Iterator<Item = &'static CatalogEntry> {
ALL_CATALOGS
.iter()
.flat_map(|(_, catalog)| catalog.iter())
.filter(|entry| entry.lint.is_some())
}
pub fn lint_by_name(name: &str) -> Option<&'static CatalogEntry> {
lint_entries().find(|entry| entry.lint == Some(name))
}
pub fn lint_of(code: DiagCode) -> Option<&'static CatalogEntry> {
lint_entries().find(|entry| entry.code == code)
}
pub fn default_level(entry: &CatalogEntry) -> Option<LintLevel> {
match entry.severity {
Severity::Error => None,
_ if entry.allow_by_default => Some(LintLevel::Allow),
Severity::Info => Some(LintLevel::Info),
Severity::Warning => Some(LintLevel::Warn),
}
}
pub type LintTable = BTreeMap<&'static str, LintLevel>;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LintLevels {
overrides: LintTable,
}
impl LintLevels {
pub fn overlay(&mut self, table: &LintTable) {
for (name, level) in table {
self.overrides.insert(name, *level);
}
}
pub fn level(&self, entry: &CatalogEntry) -> Option<LintLevel> {
entry
.lint
.and_then(|name| self.overrides.get(name).copied())
.or_else(|| default_level(entry))
}
}
#[derive(Debug, Clone, Default)]
pub struct LintScopes {
scopes: Vec<(PathBuf, LintLevels)>,
}
impl LintScopes {
pub fn insert(&mut self, dir: PathBuf, levels: LintLevels) {
self.scopes.push((dir, levels));
}
pub fn for_path(&self, path: &Path) -> Option<&LintLevels> {
self.scopes
.iter()
.filter(|(dir, _)| path.starts_with(dir))
.max_by_key(|(dir, _)| dir.components().count())
.map(|(_, levels)| levels)
}
}
pub fn apply_lint_levels(
diagnostics: &mut Vec<Diagnostic>,
sources: &SourceMap,
scopes: &LintScopes,
) {
let defaults = LintLevels::default();
diagnostics.retain_mut(|diagnostic| {
let Some(entry) = lint_of(diagnostic.code) else {
return true;
};
let levels = sources
.path(diagnostic.primary.file)
.and_then(|path| scopes.for_path(Path::new(path)))
.unwrap_or(&defaults);
let level = levels
.level(entry)
.expect("`lint_of` returns a lint row, and every lint row has a level");
match level.severity() {
Some(severity) => {
diagnostic.severity = severity;
true
}
None => false,
}
});
}
pub fn drop_allowed_by_default(diagnostics: &mut Vec<Diagnostic>) {
diagnostics
.retain(|diagnostic| !lint_of(diagnostic.code).is_some_and(|entry| entry.allow_by_default));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diag::{FileId, Span};
use rowan::{TextRange, TextSize};
#[test]
fn parse_accepts_exactly_the_four_lowercase_levels() {
for level in [
LintLevel::Allow,
LintLevel::Info,
LintLevel::Warn,
LintLevel::Deny,
] {
assert_eq!(LintLevel::parse(level.as_str()), Some(level));
}
assert_eq!(LintLevel::parse("allow"), Some(LintLevel::Allow));
assert_eq!(LintLevel::parse("info"), Some(LintLevel::Info));
assert_eq!(LintLevel::parse("warn"), Some(LintLevel::Warn));
assert_eq!(LintLevel::parse("deny"), Some(LintLevel::Deny));
assert_eq!(LintLevel::parse("Deny"), None);
assert_eq!(LintLevel::parse(""), None);
assert_eq!(LintLevel::parse("forbid"), None);
}
#[test]
fn severity_maps_each_level_and_allow_drops() {
assert_eq!(LintLevel::Allow.severity(), None);
assert_eq!(LintLevel::Info.severity(), Some(Severity::Info));
assert_eq!(LintLevel::Warn.severity(), Some(Severity::Warning));
assert_eq!(LintLevel::Deny.severity(), Some(Severity::Error));
}
#[test]
fn lint_by_name_finds_the_row_and_rejects_a_code() {
let entry = lint_by_name("missing-timing").expect("missing-timing is a lint");
assert_eq!(entry.code, DiagCode::RIDL_100);
assert!(lint_by_name("RIDL-100").is_none());
}
#[test]
fn lint_of_an_error_code_is_none() {
assert!(lint_of(DiagCode::RIDL_101).is_none());
assert_eq!(
lint_of(DiagCode::RIDL_100).and_then(|entry| entry.lint),
Some("missing-timing"),
);
}
#[test]
fn default_level_follows_the_catalogue_severity() {
let info = lint_of(DiagCode::RIDL_405).expect("RIDL-405 is a lint");
assert_eq!(default_level(info), Some(LintLevel::Info));
let warn = lint_of(DiagCode::RIDL_100).expect("RIDL-100 is a lint");
assert_eq!(default_level(warn), Some(LintLevel::Warn));
}
#[test]
fn a_row_declared_allow_defaults_to_allow() {
let style = lint_by_name("doc-comment-style").expect("doc-comment-style is a lint");
assert_eq!(default_level(style), Some(LintLevel::Allow));
assert_eq!(LintLevels::default().level(style), Some(LintLevel::Allow));
let detached = lint_of(DiagCode::TYPL_404).expect("TYPL-404 is a lint");
assert_eq!(default_level(detached), Some(LintLevel::Warn));
}
#[test]
fn drop_allowed_by_default_removes_only_allow_by_default_lints() {
let diagnostic = |code: DiagCode, severity: Severity| Diagnostic {
code,
severity,
message: String::new(),
primary: Span {
file: FileId::DETACHED,
range: TextRange::empty(TextSize::from(0)),
},
labels: Vec::new(),
fixits: Vec::new(),
};
let mut diagnostics = vec![
diagnostic(DiagCode::TYPL_410, Severity::Warning),
diagnostic(DiagCode::TYPL_404, Severity::Warning),
diagnostic(DiagCode::RIDL_101, Severity::Error),
];
drop_allowed_by_default(&mut diagnostics);
let codes: Vec<&str> = diagnostics.iter().map(|d| d.code.as_str()).collect();
assert_eq!(codes, ["TYPL-404", "RIDL-101"]);
}
fn error_row() -> &'static CatalogEntry {
ALL_CATALOGS
.iter()
.flat_map(|(_, catalog)| catalog.iter())
.find(|entry| entry.code == DiagCode::RIDL_101)
.expect("RIDL-101 is in the catalogue")
}
#[test]
fn an_error_row_has_no_default_level_and_no_level() {
let error = error_row();
assert_eq!(error.severity, Severity::Error);
assert_eq!(default_level(error), None);
assert_eq!(LintLevels::default().level(error), None);
}
fn levels(name: &'static str, level: LintLevel) -> LintLevels {
let mut levels = LintLevels::default();
levels.overlay(&LintTable::from([(name, level)]));
levels
}
#[test]
fn overlay_later_table_wins() {
let mut levels = LintLevels::default();
levels.overlay(&LintTable::from([("missing-timing", LintLevel::Deny)]));
levels.overlay(&LintTable::from([("missing-timing", LintLevel::Allow)]));
let entry = lint_by_name("missing-timing").expect("missing-timing is a lint");
assert_eq!(levels.level(entry), Some(LintLevel::Allow));
}
#[test]
fn level_falls_back_to_the_default() {
let levels = levels("missing-timing", LintLevel::Deny);
let other = lint_of(DiagCode::RIDL_405).expect("RIDL-405 is a lint");
assert_eq!(levels.level(other), default_level(other));
}
#[test]
fn longest_scope_wins() {
let entry = lint_by_name("missing-timing").expect("missing-timing is a lint");
let mut scopes = LintScopes::default();
scopes.insert(
PathBuf::from("/ws"),
levels("missing-timing", LintLevel::Warn),
);
scopes.insert(
PathBuf::from("/ws/a"),
levels("missing-timing", LintLevel::Deny),
);
let level = |path: &str| {
scopes
.for_path(Path::new(path))
.and_then(|l| l.level(entry))
};
assert_eq!(level("/ws/a/x.ridl"), Some(LintLevel::Deny));
assert_eq!(level("/ws/b/x.ridl"), Some(LintLevel::Warn));
assert_eq!(level("/other/x.ridl"), None);
}
#[test]
fn longest_scope_wins_whatever_the_insertion_order() {
let entry = lint_by_name("missing-timing").expect("missing-timing is a lint");
let mut scopes = LintScopes::default();
scopes.insert(
PathBuf::from("/ws/a"),
levels("missing-timing", LintLevel::Deny),
);
scopes.insert(
PathBuf::from("/ws"),
levels("missing-timing", LintLevel::Warn),
);
let found = scopes
.for_path(Path::new("/ws/a/x.ridl"))
.expect("in scope");
assert_eq!(found.level(entry), Some(LintLevel::Deny));
}
#[test]
fn scope_match_is_by_component() {
let mut scopes = LintScopes::default();
scopes.insert(
PathBuf::from("/ws/a"),
levels("missing-timing", LintLevel::Deny),
);
assert!(scopes.for_path(Path::new("/ws/ab/x.ridl")).is_none());
assert!(scopes.for_path(Path::new("/ws/a/x.ridl")).is_some());
}
fn diagnostic(code: DiagCode, severity: Severity, file: FileId) -> Diagnostic {
Diagnostic {
code,
severity,
message: code.as_str().to_string(),
primary: Span {
file,
range: TextRange::new(TextSize::from(0), TextSize::from(1)),
},
labels: Vec::new(),
fixits: Vec::new(),
}
}
fn deny_and_allow_scopes() -> LintScopes {
let allow_name = lint_of(DiagCode::RIDL_405)
.and_then(|entry| entry.lint)
.expect("RIDL-405 is a lint");
let mut levels = LintLevels::default();
levels.overlay(&LintTable::from([
("missing-timing", LintLevel::Deny),
(allow_name, LintLevel::Allow),
]));
let mut scopes = LintScopes::default();
scopes.insert(PathBuf::from("/ws"), levels);
scopes
}
#[test]
fn apply_rewrites_and_removes() {
let mut sources = SourceMap::new();
let file = sources.file_id("/ws/x.ridl", "interface X {}");
let scopes = deny_and_allow_scopes();
let mut diagnostics = vec![
diagnostic(DiagCode::RIDL_100, Severity::Warning, file),
diagnostic(DiagCode::RIDL_405, Severity::Info, file),
diagnostic(DiagCode::RIDL_101, Severity::Error, file),
diagnostic(DiagCode::NONE, Severity::Warning, file),
];
apply_lint_levels(&mut diagnostics, &sources, &scopes);
let kept: Vec<(DiagCode, Severity)> =
diagnostics.iter().map(|d| (d.code, d.severity)).collect();
assert_eq!(
kept,
vec![
(DiagCode::RIDL_100, Severity::Error),
(DiagCode::RIDL_101, Severity::Error),
(DiagCode::NONE, Severity::Warning),
],
);
}
#[test]
fn the_depth_lints_take_their_level_from_the_table() {
let mut sources = SourceMap::new();
let file = sources.file_id("/ws/x.rsdl", "deployment P for S {}");
let mut levels = LintLevels::default();
levels.overlay(&LintTable::from([
("depth-below-bound", LintLevel::Allow),
("depth-underivable", LintLevel::Deny),
]));
let mut scopes = LintScopes::default();
scopes.insert(PathBuf::from("/ws"), levels);
let mut diagnostics = vec![
diagnostic(DiagCode::RSDL_805, Severity::Warning, file),
diagnostic(DiagCode::RSDL_806, Severity::Warning, file),
];
apply_lint_levels(&mut diagnostics, &sources, &scopes);
let kept: Vec<(DiagCode, Severity)> =
diagnostics.iter().map(|d| (d.code, d.severity)).collect();
assert_eq!(kept, vec![(DiagCode::RSDL_806, Severity::Error)]);
}
#[test]
fn apply_uses_defaults_outside_scopes() {
let mut sources = SourceMap::new();
let outside = sources.file_id("/other/x.ridl", "interface X {}");
let scopes = deny_and_allow_scopes();
let mut diagnostics = vec![
diagnostic(DiagCode::RIDL_100, Severity::Error, outside),
diagnostic(DiagCode::RIDL_100, Severity::Error, FileId::DETACHED),
];
apply_lint_levels(&mut diagnostics, &sources, &scopes);
assert_eq!(diagnostics.len(), 2);
assert!(
diagnostics.iter().all(|d| d.severity == Severity::Warning),
"outside every scope, and with no path, RIDL-100 is at its default: {diagnostics:?}",
);
}
#[test]
fn apply_is_idempotent() {
let mut sources = SourceMap::new();
let file = sources.file_id("/ws/x.ridl", "interface X {}");
let outside = sources.file_id("/other/x.ridl", "interface X {}");
let scopes = deny_and_allow_scopes();
let mut diagnostics = vec![
diagnostic(DiagCode::RIDL_100, Severity::Warning, file),
diagnostic(DiagCode::RIDL_405, Severity::Info, file),
diagnostic(DiagCode::RIDL_101, Severity::Error, file),
diagnostic(DiagCode::NONE, Severity::Warning, file),
diagnostic(DiagCode::RIDL_100, Severity::Error, outside),
diagnostic(DiagCode::RIDL_405, Severity::Error, outside),
];
apply_lint_levels(&mut diagnostics, &sources, &scopes);
let once = diagnostics.clone();
apply_lint_levels(&mut diagnostics, &sources, &scopes);
assert_eq!(diagnostics, once);
}
}