use crate::{Diagnostic, Severity};
use anyhow::{Result, bail};
use serde::Deserialize;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Lua,
Rust,
Tl,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Level {
Allow,
Warn,
Deny,
}
impl Level {
pub fn as_str(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Warn => "warn",
Self::Deny => "deny",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"allow" => Some(Self::Allow),
"warn" => Some(Self::Warn),
"deny" => Some(Self::Deny),
_ => None,
}
}
pub fn is_on(self) -> bool {
self != Self::Allow
}
}
impl std::fmt::Display for Level {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surfaces {
LintAndFix,
FixOnly,
}
#[derive(Debug, Clone, Copy)]
pub struct Rule {
pub name: &'static str,
pub default: Level,
pub side: Side,
pub surfaces: Surfaces,
}
impl Rule {
const fn warn(name: &'static str, side: Side) -> Self {
Self {
name,
default: Level::Warn,
side,
surfaces: Surfaces::LintAndFix,
}
}
const fn allow(name: &'static str, side: Side) -> Self {
Self {
name,
default: Level::Allow,
side,
surfaces: Surfaces::LintAndFix,
}
}
const fn fix_class(name: &'static str, side: Side) -> Self {
Self {
name,
default: Level::Allow,
side,
surfaces: Surfaces::FixOnly,
}
}
pub fn is_lint(&self) -> bool {
matches!(self.surfaces, Surfaces::LintAndFix)
}
}
pub const RULES: &[Rule] = &[
Rule::warn("nil-index", Side::Lua),
Rule::warn("nil-return", Side::Lua),
Rule::allow("nil-return-unchecked", Side::Lua),
Rule::allow("htlx-available", Side::Lua),
Rule::warn("struct-fields", Side::Lua),
Rule::warn("sealed-record", Side::Lua),
Rule::warn("enum-exhaustive", Side::Lua),
Rule::warn("enum-cast", Side::Lua),
Rule::warn("enum-table", Side::Lua),
Rule::warn("union-exhaustive", Side::Lua),
Rule::warn("shadow-local", Side::Lua),
Rule::warn("no-global", Side::Lua),
Rule::allow("no-any", Side::Lua),
Rule::allow("explicit-number", Side::Lua),
Rule::allow("class-record", Side::Lua),
Rule::warn("duplicate-declaration", Side::Rust),
Rule::warn("host-module-shadowed", Side::Rust),
Rule::warn("contract", Side::Rust),
Rule::warn("contract-unenforced", Side::Rust),
Rule::warn("require-cycle", Side::Rust),
Rule::warn("tl:unknown", Side::Tl),
Rule::warn("tl:unused", Side::Tl),
Rule::warn("tl:unread", Side::Tl),
Rule::warn("tl:redeclaration", Side::Tl),
Rule::warn("tl:branch", Side::Tl),
Rule::warn("tl:hint", Side::Tl),
Rule::warn("tl:debug", Side::Tl),
Rule::fix_class("forward-ref", Side::Tl),
Rule::fix_class("tl:error", Side::Tl),
];
pub const RENAMED: &[(&str, &str)] = &[("error", "tl:error")];
fn lint_rules() -> impl Iterator<Item = (usize, &'static Rule)> {
RULES.iter().filter(|r| r.is_lint()).enumerate()
}
fn index_of(name: &str) -> Option<usize> {
lint_rules().find(|(_, r)| r.name == name).map(|(i, _)| i)
}
pub fn rule_names() -> Vec<&'static str> {
lint_rules().map(|(_, r)| r.name).collect()
}
pub fn rule_defaults() -> Vec<(&'static str, Level)> {
lint_rules().map(|(_, r)| (r.name, r.default)).collect()
}
pub fn fix_rule_names() -> Vec<&'static str> {
RULES.iter().map(|r| r.name).collect()
}
pub fn check_fix_rules(names: &[String], surface: &str) -> Result<()> {
for name in names {
if RULES.iter().any(|r| r.name == name.as_str()) {
continue;
}
if let Some((_, now)) = RENAMED.iter().find(|(old, _)| *old == name.as_str()) {
bail!(
"{surface}: `{name}` is now `{now}` — Teal's errors are named in the `tl:` \
namespace, as its warnings are"
);
}
bail!(
"{surface}: unknown rule `{name}`. Takes any rule `htl check --list-lints` \
names, or one of the classes an error's fix is filed under: {}",
fix_classes().join(", ")
);
}
Ok(())
}
pub fn fix_classes() -> Vec<&'static str> {
RULES
.iter()
.filter(|r| !r.is_lint())
.map(|r| r.name)
.collect()
}
#[derive(Debug, Clone)]
pub struct Selection {
levels: Vec<Level>,
}
impl Default for Selection {
fn default() -> Self {
Self {
levels: lint_rules().map(|(_, r)| r.default).collect(),
}
}
}
impl Selection {
pub fn parse(spec: &str) -> Result<Self> {
let mut sel = Self::default();
for item in spec
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|s| !s.is_empty())
{
let (name, level) = match item.split_once('=') {
Some((name, word)) => {
let Some(level) = Level::parse(word.trim()) else {
bail!("unknown lint level: {item} (allow, warn or deny)");
};
(name.trim(), level)
}
None => match item.strip_prefix('-') {
Some(rest) => (rest, Level::Allow),
None => (item.strip_prefix('+').unwrap_or(item), Level::Warn),
},
};
let Some(i) = index_of(name) else {
if RULES.iter().any(|r| r.name == name) {
bail!(
"not a lint rule: {name} is the class `htl fix` files an error's \
fix under, taken by `htl fix --rule` and `[fix] disable`. No \
check reports under it, so it has no level"
);
}
bail!("unknown lint rule: {item}");
};
sel.levels[i] = level;
}
Ok(sel)
}
pub fn level_of(&self, name: &str) -> Level {
index_of(name).map_or(Level::Allow, |i| self.levels[i])
}
pub fn is_on(&self, name: &str) -> bool {
self.level_of(name).is_on()
}
pub fn of_side(&self, side: Side) -> impl Iterator<Item = (&'static str, bool)> + '_ {
lint_rules()
.filter(move |(_, r)| r.side == side)
.map(|(i, r)| (r.name, self.levels[i].is_on()))
}
}
pub struct Lints {
sel: Selection,
allows: RefCell<HashMap<PathBuf, Option<AllowedLines>>>,
}
type AllowedLines = HashMap<usize, Vec<String>>;
impl Lints {
pub fn new(sel: Selection) -> Self {
Self {
sel,
allows: RefCell::new(HashMap::new()),
}
}
pub fn parse(spec: &str) -> Result<Self> {
Ok(Self::new(Selection::parse(spec)?))
}
pub fn selection(&self) -> &Selection {
&self.sel
}
pub fn on(&self, rule: &str) -> bool {
self.sel.is_on(rule)
}
pub fn level(&self, rule: &str) -> Level {
self.sel.level_of(rule)
}
pub fn keep(&self, lines: Vec<String>) -> Vec<String> {
lines
.into_iter()
.filter(|l| {
let d = Diagnostic::parse(Severity::Lint, l);
let Some(rule) = d.rule.as_deref() else {
return true;
};
self.on(rule) && !self.allowed(Path::new(&d.file), d.line, rule)
})
.collect()
}
fn allowed(&self, file: &Path, line: usize, rule: &str) -> bool {
if line == 0 || file.as_os_str().is_empty() {
return false;
}
let mut cache = self.allows.borrow_mut();
let entry = cache.entry(file.to_path_buf()).or_insert_with(|| {
std::fs::read_to_string(file)
.ok()
.map(|s| collect_allows(&s))
});
entry
.as_ref()
.and_then(|m| m.get(&line))
.is_some_and(|names| names.iter().any(|n| n == rule))
}
}
fn collect_allows(src: &str) -> AllowedLines {
let mut out: AllowedLines = HashMap::new();
for (i, line) in src.lines().enumerate() {
for (at, _) in line.match_indices("--") {
let rest = line[at + 2..].trim_start();
let Some(rest) = rest.strip_prefix("htl:") else {
continue;
};
let Some(rest) = rest.trim_start().strip_prefix("allow(") else {
continue;
};
let Some(end) = rest.find(')') else { continue };
let names = rest[..end]
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|s| !s.is_empty())
.map(str::to_string);
out.entry(i + 1).or_default().extend(names);
break;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_name_is_registered_once() {
let mut names: Vec<&str> = rule_names();
let n = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), n, "a rule name appears twice in RULES");
}
#[test]
fn a_spec_turns_rules_on_and_off_over_the_defaults() {
let sel = Selection::parse("+no-any,-nil-index").unwrap();
assert!(sel.is_on("no-any"));
assert!(!sel.is_on("nil-index"));
assert!(sel.is_on("shadow-local"));
assert!(!sel.is_on("class-record"));
}
#[test]
fn the_defaults_are_warn_except_the_opinions_and_the_two_that_are_not() {
for (name, level) in rule_defaults() {
let want = match name {
"no-any"
| "explicit-number"
| "class-record"
| "nil-return-unchecked"
| "htlx-available" => Level::Allow,
_ => Level::Warn,
};
assert_eq!(level, want, "{name}");
}
}
#[test]
fn a_spec_sets_a_level_by_name() {
let sel = Selection::parse("nil-index=deny,tl:hint=allow,no-any=warn").unwrap();
assert_eq!(sel.level_of("nil-index"), Level::Deny);
assert_eq!(sel.level_of("tl:hint"), Level::Allow);
assert_eq!(sel.level_of("no-any"), Level::Warn);
assert_eq!(sel.level_of("shadow-local"), Level::Warn);
assert_eq!(sel.level_of("class-record"), Level::Allow);
}
#[test]
fn the_plus_and_minus_spelling_is_the_same_as_a_level() {
let short = Selection::parse("+no-any,-nil-index").unwrap();
let long = Selection::parse("no-any=warn,nil-index=allow").unwrap();
for (name, _) in rule_defaults() {
assert_eq!(short.level_of(name), long.level_of(name), "{name}");
}
}
#[test]
fn later_entries_win_so_a_flag_can_raise_what_a_file_set() {
let sel = Selection::parse("nil-index=allow,nil-index=deny").unwrap();
assert_eq!(sel.level_of("nil-index"), Level::Deny);
}
#[test]
fn an_unknown_level_is_refused_as_written() {
let err = Selection::parse("nil-index=error").unwrap_err().to_string();
assert_eq!(
err,
"unknown lint level: nil-index=error (allow, warn or deny)"
);
assert!(Selection::parse("no-such-rule=deny").is_err());
}
#[test]
fn the_names_the_project_layer_prints_are_names_a_spec_takes() {
for rule in [
"require-cycle",
"duplicate-declaration",
"host-module-shadowed",
"contract",
"contract-unenforced",
] {
let sel = Selection::parse(&format!("-{rule}")).unwrap();
assert!(!sel.is_on(rule), "{rule} stayed on");
}
}
#[test]
fn a_teal_warning_kind_is_a_name_a_spec_takes() {
let sel = Selection::parse("-tl:hint,-tl:unused").unwrap();
assert!(!sel.is_on("tl:hint"));
assert!(!sel.is_on("tl:unused"));
assert!(sel.is_on("tl:redeclaration"), "the rest keep their default");
assert!(Selection::parse("-hint").is_err());
}
#[test]
fn an_unknown_name_is_refused_as_written() {
let err = Selection::parse("+nil-idex").unwrap_err().to_string();
assert_eq!(err, "unknown lint rule: +nil-idex");
}
#[test]
fn the_listing_prints_fewer_names_than_a_fix_filter_takes() {
let listed = rule_names();
let fixable = fix_rule_names();
for name in &listed {
assert!(fixable.contains(name), "{name} is not a name a fix takes");
Selection::parse(&format!("{name}=deny"))
.unwrap_or_else(|e| panic!("the listing prints {name} and a spec refuses it: {e}"));
}
let only_fix: Vec<&str> = fixable
.iter()
.copied()
.filter(|n| !listed.contains(n))
.collect();
assert_eq!(only_fix, fix_classes());
assert_eq!(only_fix, ["forward-ref", "tl:error"], "{only_fix:?}");
}
#[test]
fn a_fix_class_has_no_level_to_set() {
for name in fix_classes() {
let err = Selection::parse(&format!("-{name}"))
.unwrap_err()
.to_string();
assert!(err.starts_with("not a lint rule: "), "{err}");
assert!(
err.contains(name) && err.contains("htl fix --rule"),
"{err}"
);
assert!(!Selection::default().is_on(name), "{name}");
}
}
#[test]
fn a_fix_filter_takes_a_class_and_a_rule_and_refuses_a_typo() {
let names = |s: &[&str]| s.iter().map(|n| (*n).to_string()).collect::<Vec<_>>();
check_fix_rules(
&names(&["tl:error", "forward-ref", "no-global"]),
"[fix] disable",
)
.unwrap();
let err = check_fix_rules(&names(&["forwardref"]), "htl fix --rule")
.unwrap_err()
.to_string();
assert!(
err.contains("htl fix --rule: unknown rule `forwardref`"),
"{err}"
);
assert!(
err.contains("--list-lints") && err.contains("tl:error"),
"{err}"
);
}
#[test]
fn the_old_error_spelling_names_what_replaced_it() {
for surface in ["htl fix --rule", "[fix] disable", "[fix] unsafe"] {
let err = check_fix_rules(&["error".to_string()], surface)
.unwrap_err()
.to_string();
assert_eq!(
err,
format!(
"{surface}: `error` is now `tl:error` — Teal's errors are named in the \
`tl:` namespace, as its warnings are"
)
);
}
}
#[test]
fn an_allow_comment_names_rules_for_its_own_line() {
let src = "local t = {}\nlocal x = t[1].y -- htl: allow(nil-index, shadow-local)\n";
let allows = collect_allows(src);
assert_eq!(allows.get(&1), None);
assert_eq!(
allows.get(&2).unwrap(),
&vec!["nil-index".to_string(), "shadow-local".to_string()]
);
}
#[test]
fn a_finding_is_dropped_by_the_rule_being_off() {
let lints = Lints::parse("-require-cycle").unwrap();
let kept = lints.keep(vec![
"a.tl:1:1: a -> b -> a [htl require-cycle]".to_string(),
"a.tl:2:1: x is declared more than once [htl duplicate-declaration]".to_string(),
]);
assert_eq!(kept.len(), 1);
assert!(kept[0].contains("duplicate-declaration"));
}
}