use std::cell::OnceCell;
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::sync::{Arc, LazyLock};
use rowan::{TextRange, TextSize};
use crate::ast::{AstToken, CallExpr, Expr};
use crate::config::{LintConfig, RulesConfig};
use crate::index::PackageIndex;
use crate::julia_version::VersionRange;
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::linter::include_graph::IncludeProblem;
use crate::linter::suppression::{DirectiveUsage, SuppressionMap};
use crate::resolve::{
ModulePath, Namespace, PackageSource, Resolution, Resolver, has_unresolvable_using,
};
use crate::semantic::{BindingKind, FileControlFlow, IdentRef, SemanticModel};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
pub mod correctness;
mod file_scan;
pub mod matchers;
pub mod readability;
pub mod suspicious;
pub(crate) use file_scan::FileScan;
pub struct Example {
pub caption: &'static str,
pub source: &'static str,
}
pub fn all_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(correctness::UnusedBinding),
Box::new(correctness::UnusedImport),
Box::new(correctness::DuplicateArgument),
Box::new(correctness::DuplicateKeywordArgument),
Box::new(correctness::UnusedArgument),
Box::new(correctness::UndefinedName),
Box::new(correctness::BreakOutsideLoop),
Box::new(correctness::ConstLocal),
Box::new(correctness::GlobalConstInFunction),
Box::new(correctness::LocalConst),
Box::new(correctness::NotEqDefinition),
Box::new(correctness::UnusedTypeParameter),
Box::new(correctness::MissingIncludeFile),
Box::new(correctness::IncludeCycle),
Box::new(correctness::DuplicateInclude),
Box::new(correctness::DuplicateMethod),
Box::new(correctness::JuliaVersionCompat),
Box::new(correctness::CallArity),
Box::new(correctness::RedefinedConstant),
Box::new(correctness::TypePiracy),
Box::new(correctness::UnreachableCode),
Box::new(suspicious::AssignmentInCondition),
Box::new(suspicious::NothingComparison),
Box::new(suspicious::MissingComparison),
Box::new(suspicious::ConstantCondition),
Box::new(suspicious::ModuleShadowsParent),
Box::new(suspicious::LoopVariableShadow),
Box::new(suspicious::IndexFromLength),
Box::new(suspicious::DiscouragedFunction),
Box::new(suspicious::TypeofComparison),
Box::new(readability::ComparisonNegation),
Box::new(readability::LengthZero),
Box::new(readability::RedundantBoolean),
]
}
pub fn all_rule_ids() -> Vec<&'static str> {
all_rules().iter().map(|r| r.id()).collect()
}
static SHIPPED_RULE_IDS: LazyLock<BTreeSet<&'static str>> =
LazyLock::new(|| all_rule_ids().into_iter().collect());
pub fn is_shipped_rule(id: &str) -> bool {
SHIPPED_RULE_IDS.contains(id)
}
#[derive(Debug, Clone, Default)]
pub struct EnabledRules(Vec<&'static str>);
impl EnabledRules {
pub fn contains(&self, id: &str) -> bool {
self.0.contains(&id)
}
}
pub struct RuleContext<'a> {
pub path: Option<&'a Path>,
pub root: &'a SyntaxNode,
pub model: &'a SemanticModel,
pub resolution: Option<ResolutionContext<'a>>,
pub includes: &'a [IncludeProblem],
pub julia_target: Option<VersionRange>,
pub config: &'a RulesConfig,
pub suppressions: &'a SuppressionMap,
pub enabled_rules: &'a EnabledRules,
cache: RuleCache<'a>,
}
static DEFAULT_RULES_CONFIG: LazyLock<RulesConfig> = LazyLock::new(RulesConfig::default);
static EMPTY_SUPPRESSIONS: LazyLock<SuppressionMap> = LazyLock::new(SuppressionMap::default);
static EMPTY_ENABLED_RULES: EnabledRules = EnabledRules(Vec::new());
#[derive(Default)]
struct RuleCache<'a> {
resolver: OnceCell<Option<Resolver<'a, dyn PackageSource + 'a>>>,
scan: OnceCell<FileScan>,
cfg: OnceCell<FileControlFlow>,
unresolvable_using: OnceCell<bool>,
idents_by_range: OnceCell<HashMap<TextRange, usize>>,
trusts_resolution: OnceCell<bool>,
}
impl<'a> RuleContext<'a> {
pub fn new(path: Option<&'a Path>, root: &'a SyntaxNode, model: &'a SemanticModel) -> Self {
Self {
path,
root,
model,
resolution: None,
includes: &[],
julia_target: None,
config: &DEFAULT_RULES_CONFIG,
suppressions: &EMPTY_SUPPRESSIONS,
enabled_rules: &EMPTY_ENABLED_RULES,
cache: RuleCache::default(),
}
}
#[must_use]
pub fn with_resolution(mut self, resolution: Option<ResolutionContext<'a>>) -> Self {
self.resolution = resolution;
self
}
#[must_use]
pub fn with_includes(mut self, includes: &'a [IncludeProblem]) -> Self {
self.includes = includes;
self
}
#[must_use]
pub fn with_julia_target(mut self, julia_target: Option<VersionRange>) -> Self {
self.julia_target = julia_target;
self
}
#[must_use]
pub fn with_config(mut self, config: &'a RulesConfig) -> Self {
self.config = config;
self
}
#[must_use]
pub fn with_suppressions(mut self, suppressions: &'a SuppressionMap) -> Self {
self.suppressions = suppressions;
self
}
#[must_use]
pub fn with_enabled_rules(mut self, enabled_rules: &'a EnabledRules) -> Self {
self.enabled_rules = enabled_rules;
self
}
pub fn resolver(&self) -> Option<&Resolver<'a, dyn PackageSource + 'a>> {
self.cache
.resolver
.get_or_init(|| {
let resolution = self.resolution.as_ref()?;
Some(
Resolver::new(self.model, resolution.packages)
.with_workspace(resolution.workspace.clone()),
)
})
.as_ref()
}
pub fn has_unresolvable_using(&self) -> bool {
*self.cache.unresolvable_using.get_or_init(|| {
self.resolution.as_ref().is_some_and(|resolution| {
has_unresolvable_using(
self.model,
resolution.packages,
resolution.workspace.as_ref(),
)
})
})
}
pub(crate) fn file_scan(&self) -> &FileScan {
self.cache.scan.get_or_init(|| FileScan::collect(self.root))
}
pub fn control_flow(&self) -> &FileControlFlow {
self.cache
.cfg
.get_or_init(|| FileControlFlow::build(self.root))
}
pub fn resolves_to_base(&self, call: &CallExpr) -> bool {
let Some(Expr::Name(callee)) = call.callee() else {
return false;
};
let Some(ident) = callee.ident() else {
return false;
};
self.read_resolves_to_base(ident.syntax())
}
pub fn read_resolves_to_base(&self, token: &SyntaxToken) -> bool {
if token.kind() != SyntaxKind::IDENT || !self.trusts_resolution() {
return false;
}
let Some(resolver) = self.resolver() else {
return false;
};
let range = token.text_range();
let Some(ident) = self.ident_at(range) else {
return false;
};
if ident.is_macro || self.file_scan().in_skipped(range) {
return false;
}
matches!(
resolver.resolve(&ident.name, range.start(), Namespace::Value),
Resolution::System { .. }
)
}
pub fn name_resolves_to_base(&self, name: &str, at: TextSize) -> bool {
if !self.trusts_resolution() {
return false;
}
let Some(resolver) = self.resolver() else {
return false;
};
matches!(
resolver.resolve(name, at, Namespace::Value),
Resolution::System { .. }
)
}
pub fn read_is_shadowed_locally(&self, token: &SyntaxToken) -> bool {
if token.kind() != SyntaxKind::IDENT {
return false;
}
let Some(resolver) = self.resolver() else {
return false;
};
let range = token.text_range();
let Some(ident) = self.ident_at(range) else {
return false;
};
if ident.is_macro || self.file_scan().in_skipped(range) {
return false;
}
match resolver.resolve(&ident.name, range.start(), Namespace::Value) {
Resolution::Binding(id) => self.model.binding(id).kind != BindingKind::Import,
_ => false,
}
}
pub fn trusts_resolution(&self) -> bool {
*self.cache.trusts_resolution.get_or_init(|| {
let Some(resolution) = &self.resolution else {
return false;
};
if self.has_unresolvable_using() {
return false;
}
let scan = self.file_scan();
!scan.calls_eval
&& !scan.dynamic_include
&& (resolution.workspace.is_some() || !scan.literal_include)
})
}
fn ident_at(&self, range: TextRange) -> Option<&IdentRef> {
let index = self.cache.idents_by_range.get_or_init(|| {
let mut map = HashMap::with_capacity(self.model.idents().len());
for (i, ident) in self.model.idents().iter().enumerate() {
map.entry(ident.range).or_insert(i);
}
map
});
index.get(&range).map(|&i| &self.model.idents()[i])
}
}
pub struct ResolutionContext<'a> {
pub packages: &'a dyn PackageSource,
pub workspace: Option<(Arc<PackageIndex>, ModulePath)>,
}
pub trait Rule: Send + Sync {
fn id(&self) -> &'static str;
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn default_enabled(&self) -> bool {
true
}
fn description(&self) -> &'static str {
""
}
fn examples(&self) -> &'static [Example] {
&[]
}
fn example_julia_target(&self) -> Option<VersionRange> {
None
}
fn interests(&self) -> &'static [SyntaxKind] {
&[]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let _ = (el, ctx, sink);
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let _ = (ctx, sink);
}
fn check_suppressions(
&self,
ctx: &RuleContext<'_>,
used: &DirectiveUsage,
sink: &mut Vec<Diagnostic>,
) {
let _ = (ctx, used, sink);
}
}
struct ConfiguredRule {
rule: Box<dyn Rule>,
severity: Severity,
}
pub struct ResolvedRules {
rules: Vec<ConfiguredRule>,
by_kind: Vec<Vec<usize>>,
any_node_rules: bool,
julia_target: Option<VersionRange>,
rules_config: RulesConfig,
enabled: EnabledRules,
}
impl ResolvedRules {
pub fn resolve(config: &LintConfig) -> (Self, Vec<String>) {
let all = all_rules();
let select = config.select.as_deref();
let mut unknown = Vec::new();
for id in select
.into_iter()
.flatten()
.chain(&config.ignore)
.chain(config.severity.keys())
{
let recognized = all.iter().any(|rule| rule.id() == id);
if !recognized && !unknown.contains(id) {
unknown.push(id.clone());
}
}
let rules: Vec<ConfiguredRule> = all
.into_iter()
.filter(|rule| {
let enabled = match select {
Some(selected) => selected.iter().any(|id| id == rule.id()),
None => rule.default_enabled(),
};
enabled && !config.ignore.iter().any(|id| id == rule.id())
})
.map(|rule| {
let severity = config
.severity
.get(rule.id())
.copied()
.unwrap_or(rule.default_severity());
ConfiguredRule { rule, severity }
})
.collect();
let mut by_kind: Vec<Vec<usize>> = vec![Vec::new(); SyntaxKind::COUNT];
let mut any_node_rules = false;
for (i, configured) in rules.iter().enumerate() {
for kind in configured.rule.interests() {
by_kind[*kind as usize].push(i);
any_node_rules = true;
}
}
let enabled = EnabledRules(rules.iter().map(|c| c.rule.id()).collect());
(
Self {
rules,
by_kind,
any_node_rules,
julia_target: None,
rules_config: config.rules.clone(),
enabled,
},
unknown,
)
}
#[cfg(test)]
fn with_rules(rules: Vec<Box<dyn Rule>>) -> Self {
let rules: Vec<ConfiguredRule> = rules
.into_iter()
.map(|rule| {
let severity = rule.default_severity();
ConfiguredRule { rule, severity }
})
.collect();
let mut by_kind: Vec<Vec<usize>> = vec![Vec::new(); SyntaxKind::COUNT];
let mut any_node_rules = false;
for (i, configured) in rules.iter().enumerate() {
for kind in configured.rule.interests() {
by_kind[*kind as usize].push(i);
any_node_rules = true;
}
}
let enabled = EnabledRules(rules.iter().map(|c| c.rule.id()).collect());
Self {
rules,
by_kind,
any_node_rules,
julia_target: None,
rules_config: RulesConfig::default(),
enabled,
}
}
#[must_use]
pub fn with_julia_target(mut self, target: Option<VersionRange>) -> Self {
self.julia_target = target;
self
}
pub fn julia_target(&self) -> Option<VersionRange> {
self.julia_target
}
pub fn rules_config(&self) -> &RulesConfig {
&self.rules_config
}
pub fn enabled(&self) -> &EnabledRules {
&self.enabled
}
pub fn run(&self, ctx: &RuleContext<'_>) -> Vec<Diagnostic> {
let mut all = Vec::new();
if self.any_node_rules {
for el in ctx.root.descendants_with_tokens() {
for &i in &self.by_kind[el.kind() as usize] {
let before = all.len();
self.rules[i].rule.check(&el, ctx, &mut all);
stamp_severity(&mut all[before..], self.rules[i].severity);
}
}
}
for configured in &self.rules {
let before = all.len();
configured.rule.check_file(ctx, &mut all);
stamp_severity(&mut all[before..], configured.severity);
}
let used = ctx.suppressions.filter(&mut all);
let mut post = Vec::new();
for configured in &self.rules {
let before = post.len();
configured.rule.check_suppressions(ctx, &used, &mut post);
stamp_severity(&mut post[before..], configured.severity);
}
if !post.is_empty() {
post.retain(|d| !ctx.suppressions.is_suppressed(d.rule, d.range));
all.append(&mut post);
}
if let Some(path) = ctx.path {
for diag in &mut all {
diag.path = Some(path.to_path_buf());
}
}
all.sort_by_key(|d| (d.range.start(), d.range.end(), d.rule));
all
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
fn stamp_severity(diags: &mut [Diagnostic], severity: Severity) {
for diag in diags {
diag.severity = severity;
}
}
#[cfg(test)]
mod base_resolution_tests {
use super::*;
use crate::ast::AstNode;
use crate::index::harvest_tree;
use crate::index::model::ModuleIndex;
use crate::semantic::SemanticModel;
use std::collections::BTreeMap;
type Library = BTreeMap<String, Arc<PackageIndex>>;
fn pkg(name: &str, src: &str) -> Arc<PackageIndex> {
let parsed = crate::parser::parse(src);
assert!(parsed.diagnostics.is_empty(), "fixture must parse clean");
Arc::new(PackageIndex {
name: name.to_string(),
root: ModuleIndex {
name: name.to_string(),
..harvest_tree(&parsed.cst)
},
members: Vec::new(),
member_modules: Default::default(),
diagnostics: Vec::new(),
})
}
fn library(extra: &[(&str, &str)]) -> Library {
let mut lib = Library::from([
("Base".to_string(), pkg("Base", "export length\n")),
("Core".to_string(), pkg("Core", "")),
]);
for (name, src) in extra {
lib.insert(name.to_string(), pkg(name, src));
}
lib
}
fn ask(
src: &str,
lib: Option<&Library>,
ws: Option<Arc<PackageIndex>>,
ask: impl Fn(&RuleContext<'_>, &SyntaxNode) -> bool,
) -> bool {
let parsed = crate::parser::parse(src);
assert!(parsed.diagnostics.is_empty(), "fixture must parse clean");
let model = SemanticModel::build(&parsed.cst);
let ctx =
RuleContext::new(None, &parsed.cst, &model).with_resolution(lib.map(|packages| {
ResolutionContext {
packages,
workspace: ws.map(|pkg| (pkg, Vec::new())),
}
}));
ask(&ctx, &parsed.cst)
}
fn call_is_base(
src: &str,
prefix: &str,
lib: Option<&Library>,
ws: Option<Arc<PackageIndex>>,
) -> bool {
ask(src, lib, ws, |ctx, root| {
let call = root
.descendants()
.filter_map(CallExpr::cast)
.filter(|call| call.syntax().text().to_string().starts_with(prefix))
.last()
.expect("fixture must contain the call");
ctx.resolves_to_base(&call)
})
}
fn read_is_base(src: &str, name: &str, lib: Option<&Library>) -> bool {
ask(src, lib, None, |ctx, root| {
let token = root
.descendants_with_tokens()
.filter_map(|el| el.into_token())
.filter(|t| t.kind() == SyntaxKind::IDENT && t.text() == name)
.last()
.expect("fixture must contain the read");
ctx.read_resolves_to_base(&token)
})
}
#[test]
fn bare_base_call_is_confirmed() {
assert!(call_is_base(
"length(x)\n",
"length",
Some(&library(&[])),
None
));
}
#[test]
fn local_shadow_is_not_base() {
assert!(!call_is_base(
"length(x) = 1\nlength(y)\n",
"length(y)",
Some(&library(&[])),
None
));
assert!(!call_is_base(
"function f(length, x)\n length(x)\nend\n",
"length(x)",
Some(&library(&[])),
None
));
}
#[test]
fn definition_site_is_not_a_base_call() {
assert!(!call_is_base(
"length(x) = 1\n",
"length",
Some(&library(&[])),
None
));
}
#[test]
fn qualified_callee_is_not_confirmed() {
assert!(!call_is_base(
"Base.length(x)\n",
"Base.length",
Some(&library(&[])),
None
));
}
#[test]
fn computed_callee_is_not_confirmed() {
assert!(!call_is_base("f()(x)\n", "f()(", Some(&library(&[])), None));
}
#[test]
fn using_masked_export_is_not_base() {
let lib = library(&[("A", "export length\n")]);
assert!(!call_is_base(
"using A\nlength(x)\n",
"length",
Some(&lib),
None
));
}
#[test]
fn explicit_import_is_not_confirmed() {
let lib = library(&[("A", "export length\n")]);
assert!(!call_is_base(
"import A: length\nlength(x)\n",
"length(x)",
Some(&lib),
None
));
}
#[test]
fn workspace_sibling_is_not_base() {
let ws = pkg("MyPkg", "length(x) = 1\n");
assert!(!call_is_base(
"length(x)\n",
"length",
Some(&library(&[])),
Some(ws)
));
}
#[test]
fn unresolvable_using_bails_the_file() {
assert!(!call_is_base(
"using Unharvested\nlength(x)\n",
"length",
Some(&library(&[])),
None
));
}
#[test]
fn eval_or_unfollowable_include_bails_the_file() {
assert!(!call_is_base(
"eval(ex)\nlength(x)\n",
"length(x)",
Some(&library(&[])),
None
));
assert!(!call_is_base(
"@eval f() = 1\nlength(x)\n",
"length(x)",
Some(&library(&[])),
None
));
assert!(!call_is_base(
"include(\"other.jl\")\nlength(x)\n",
"length(x)",
Some(&library(&[])),
None
));
assert!(call_is_base(
"include(\"other.jl\")\nlength(x)\n",
"length(x)",
Some(&library(&[])),
Some(pkg("MyPkg", ""))
));
}
#[test]
fn macro_call_and_quoted_code_are_not_confirmed() {
assert!(!call_is_base(
"@assert length(x) > 0\n",
"length",
Some(&library(&[])),
None
));
assert!(!call_is_base(
"ex = :(length(x))\n",
"length",
Some(&library(&[])),
None
));
}
#[test]
fn no_resolution_context_is_not_confirmed() {
assert!(!call_is_base("length(x)\n", "length", None, None));
}
#[test]
fn bare_read_is_confirmed() {
assert!(read_is_base(
"map(length, xs)\n",
"length",
Some(&library(&[]))
));
}
#[test]
fn non_reads_spelled_like_a_base_name_are_not_confirmed() {
assert!(!read_is_base(
"f(x.length)\n",
"length",
Some(&library(&[]))
));
assert!(!read_is_base(
"f(length = 1)\n",
"length",
Some(&library(&[]))
));
}
#[test]
fn shadowed_read_is_not_confirmed() {
assert!(!read_is_base(
"length = 3\nmap(length, xs)\n",
"length",
Some(&library(&[]))
));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn resolve_flags_unknown_select_and_ignore_ids() {
let config = LintConfig {
select: Some(ids(&["unused-binding", "made-up-rule"])),
ignore: ids(&["also-bogus"]),
..Default::default()
};
let (_rules, unknown) = ResolvedRules::resolve(&config);
assert_eq!(unknown, ids(&["made-up-rule", "also-bogus"]));
}
#[test]
fn resolve_reports_no_unknowns_for_valid_ids() {
let config = LintConfig {
ignore: ids(&["unused-import"]),
..Default::default()
};
let (_rules, unknown) = ResolvedRules::resolve(&config);
assert!(unknown.is_empty());
}
#[test]
fn control_flow_is_built_once_and_shared() {
let src = "function f()\n return 1\n dead()\nend\n";
let parsed = crate::parser::parse(src);
let model = SemanticModel::build(&parsed.cst);
let ctx = RuleContext::new(None, &parsed.cst, &model);
let first = ctx.control_flow();
assert!(std::ptr::eq(first, ctx.control_flow()), "memoized per file");
let start = src.find("dead()").unwrap();
let range = TextRange::new(
u32::try_from(start).unwrap().into(),
u32::try_from(start + "dead()".len()).unwrap().into(),
);
assert!(first.is_unreachable(range));
}
#[test]
fn resolve_dedupes_repeated_unknown_ids() {
let config = LintConfig {
select: Some(ids(&["typo", "typo"])),
ignore: ids(&["typo"]),
..Default::default()
};
let (_rules, unknown) = ResolvedRules::resolve(&config);
assert_eq!(unknown, ids(&["typo"]));
}
#[test]
fn resolve_flags_unknown_severity_keys() {
let config = LintConfig {
severity: [("no-such-rule".to_string(), Severity::Error)].into(),
..Default::default()
};
let (_rules, unknown) = ResolvedRules::resolve(&config);
assert_eq!(unknown, ids(&["no-such-rule"]));
}
#[test]
fn resolved_severity_is_override_or_rule_default() {
let config = LintConfig {
severity: [("unused-binding".to_string(), Severity::Error)].into(),
..Default::default()
};
let (rules, _) = ResolvedRules::resolve(&config);
let severity_of = |id: &str| {
rules
.rules
.iter()
.find(|c| c.rule.id() == id)
.map(|c| c.severity)
.unwrap()
};
assert_eq!(severity_of("unused-binding"), Severity::Error);
assert_eq!(severity_of("duplicate-argument"), Severity::Error);
assert_eq!(severity_of("unused-import"), Severity::Warning);
}
#[test]
fn resolve_carries_the_per_rule_option_tables() {
let mut config = LintConfig::default();
config
.rules
.discouraged_function
.extend_functions
.insert("sleep".to_string(), "use a timer".to_string());
let (rules, _) = ResolvedRules::resolve(&config);
assert_eq!(
rules.rules_config().discouraged_function.lookup("sleep"),
Some("use a timer")
);
}
#[test]
fn a_context_with_no_config_sees_the_defaults() {
let parsed = crate::parser::parse("x = 1\n");
let model = SemanticModel::build(&parsed.cst);
let ctx = RuleContext::new(None, &parsed.cst, &model);
assert!(ctx.config.discouraged_function.lookup("exit").is_some());
}
}
#[cfg(test)]
mod suppression_dispatch_tests {
use super::*;
use crate::semantic::SemanticModel;
struct FakeError;
impl Rule for FakeError {
fn id(&self) -> &'static str {
"fake-error"
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::CALL_EXPR]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
sink.push(Diagnostic::new("fake-error", el.text_range(), "boom"));
}
}
struct FakePost;
impl Rule for FakePost {
fn id(&self) -> &'static str {
"fake-post"
}
fn check_suppressions(
&self,
ctx: &RuleContext<'_>,
used: &DirectiveUsage,
sink: &mut Vec<Diagnostic>,
) {
for (i, directive) in ctx.suppressions.directives().iter().enumerate() {
if !used.is_used(i) {
sink.push(Diagnostic::new("fake-post", directive.comment, "unused"));
}
}
}
}
fn run_on(src: &str, rules: Vec<Box<dyn Rule>>) -> Vec<Diagnostic> {
let parsed = crate::parser::parse(src);
assert!(parsed.diagnostics.is_empty(), "fixture must parse clean");
let model = SemanticModel::build(&parsed.cst);
let suppressions = crate::linter::suppression::SuppressionMap::build(&parsed.cst);
let resolved = ResolvedRules::with_rules(rules);
let ctx = RuleContext::new(None, &parsed.cst, &model)
.with_suppressions(&suppressions)
.with_enabled_rules(resolved.enabled());
resolved.run(&ctx)
}
#[test]
fn run_filters_suppressed_findings() {
let diags = run_on(
"# fatou-ignore fake-error: quiet\nf(1)\n",
vec![Box::new(FakeError)],
);
assert!(diags.is_empty(), "expected no findings, got {diags:?}");
}
#[test]
fn post_pass_reports_directives_that_matched_nothing() {
let src = "# fatou-ignore fake-error: stale\nx = 1\n";
let diags = run_on(src, vec![Box::new(FakeError), Box::new(FakePost)]);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].rule, "fake-post");
assert_eq!(&src[diags[0].range], "# fatou-ignore fake-error: stale");
}
#[test]
fn post_pass_findings_are_refiltered() {
let src = "# fatou-ignore-file fake-post: hush\n# fatou-ignore fake-error: stale\nx = 1\n";
let diags = run_on(src, vec![Box::new(FakeError), Box::new(FakePost)]);
assert_eq!(diags.len(), 1, "got {diags:?}");
assert_eq!(&src[diags[0].range], "# fatou-ignore-file fake-post: hush");
}
#[test]
fn enabled_rules_reflects_the_resolved_set() {
let config = LintConfig {
select: Some(vec!["unused-binding".to_string()]),
..LintConfig::default()
};
let (resolved, unknown) = ResolvedRules::resolve(&config);
assert!(unknown.is_empty());
assert!(resolved.enabled().contains("unused-binding"));
assert!(!resolved.enabled().contains("discouraged-function"));
}
}