feat(natu): support inline suppressions

This commit was merged in pull request #105.
This commit is contained in:
2026-09-26 23:30:01 -04:00
parent ac5cc8d659
commit dd82367dcb
5 changed files with 49 additions and 21 deletions

View File

@@ -111,7 +111,7 @@ pub fn future_sight(files: Vec<SourceFile>) -> status::Result<Report> {
} }
let graph = ScopeGraph::from_file(&parsed); let graph = ScopeGraph::from_file(&parsed);
diagnostics.extend(rules::run_rules(&parsed, &graph, &file.path)); diagnostics.extend(rules::run_rules(&parsed, &graph, &file.source));
} }
Ok(Report { Ok(Report {

View File

@@ -66,10 +66,10 @@ fn natu_reports_out_of_order_let_bindings() -> googletest::Result<()> {
} }
#[gtest] #[gtest]
fn natu_preserves_nixos_cfg_bindings() -> googletest::Result<()> { fn natu_preserves_suppressed_bindings() -> googletest::Result<()> {
let report = natu::future_sight(vec![SourceFile { let report = natu::future_sight(vec![SourceFile {
path: PathBuf::from("configs/nixos/test.nix"), path: PathBuf::from("testdata/nolint.nix"),
source: "{ config, ... }: let\n cfg = config.example;\n value = cfg;\nin value\n".into(), source: "{ config, ... }: let\n cfg = config.example; # NOLINT(let_order)\n value = cfg;\nin value\n".into(),
}]) }])
.or_fail()?; .or_fail()?;

View File

@@ -1,5 +1,3 @@
use std::path::Path;
use crate::FileId; use crate::FileId;
use crate::ast; use crate::ast;
use crate::rules::manual_inherit::ManualInherit; use crate::rules::manual_inherit::ManualInherit;
@@ -61,7 +59,7 @@ pub trait TokenRule {
fn check(token: &Self::Token) -> Option<&'static str>; fn check(token: &Self::Token) -> Option<&'static str>;
} }
pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph, path: &Path) -> Vec<Diagnostic> { pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph, source: &str) -> Vec<Diagnostic> {
let node_diagnostics = file let node_diagnostics = file
.nodes .nodes
.iter() .iter()
@@ -78,9 +76,8 @@ pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph, path: &Path) -> Vec
.flat_map(|token| [run_token_rule::<UnquotedUri>(token)]) .flat_map(|token| [run_token_rule::<UnquotedUri>(token)])
.flatten(); .flatten();
let ignore_cfg = let let_order_diagnostics =
path.starts_with("configs/nixos") || path.to_string_lossy().contains("/configs/nixos/"); let_order::check(graph, source)
let let_order_diagnostics = let_order::check(graph, ignore_cfg)
.into_iter() .into_iter()
.map(|finding| Finding { .map(|finding| Finding {
rule_id: "let_order", rule_id: "let_order",
@@ -93,6 +90,7 @@ pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph, path: &Path) -> Vec
node_diagnostics node_diagnostics
.chain(token_diagnostics) .chain(token_diagnostics)
.chain(let_order_diagnostics) .chain(let_order_diagnostics)
.filter(|finding| !is_suppressed(source, finding.range, finding.rule_id))
.map(|finding| Diagnostic { .map(|finding| Diagnostic {
file_id: file.file_id, file_id: file.file_id,
finding, finding,
@@ -100,6 +98,24 @@ pub fn run_rules(file: &ast::ParsedFile, graph: &ScopeGraph, path: &Path) -> Vec
.collect() .collect()
} }
pub fn is_suppressed(source: &str, range: ast::SourceRange, rule_id: &str) -> bool {
let line = source[range.start..].lines().next().unwrap_or_default();
let Some(comment) = line.split_once('#').map(|(_, comment)| comment.trim()) else {
return false;
};
if comment == "NOLINT" {
return true;
}
comment
.strip_prefix("NOLINT(")
.and_then(|rules| rules.strip_suffix(')'))
.is_some_and(|rules| {
rules
.split(',')
.any(|candidate| candidate.trim() == rule_id)
})
}
fn run_node_rule<R>(node: &ast::Node) -> Option<Finding> fn run_node_rule<R>(node: &ast::Node) -> Option<Finding>
where where
R: NodeRule, R: NodeRule,

View File

@@ -1,5 +1,6 @@
use crate::{ use crate::{
ast, ast,
rules::is_suppressed,
scope_graph::{LetScope, NodeId, ScopeGraph}, scope_graph::{LetScope, NodeId, ScopeGraph},
}; };
@@ -8,19 +9,19 @@ pub struct Finding {
pub expected_order: Vec<String>, pub expected_order: Vec<String>,
} }
pub fn check(graph: &ScopeGraph, ignore_cfg: bool) -> Vec<Finding> { pub fn check(graph: &ScopeGraph, source: &str) -> Vec<Finding> {
graph graph
.let_scopes() .let_scopes()
.iter() .iter()
.flat_map(|let_scope| { .flat_map(|let_scope| {
let expected = expected_order(graph, let_scope) let expected = expected_order(graph, let_scope)
.into_iter() .into_iter()
.filter(|declaration| !ignore_cfg || binding_name(let_scope, *declaration) != "cfg") .filter(|declaration| !binding_suppressed(let_scope, *declaration, source))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let actual = let_scope let actual = let_scope
.bindings .bindings
.iter() .iter()
.filter(|binding| !ignore_cfg || binding.name != "cfg") .filter(|binding| !is_suppressed(source, binding.range, "let_order"))
.map(|binding| binding.declaration) .map(|binding| binding.declaration)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -35,6 +36,15 @@ pub fn check(graph: &ScopeGraph, ignore_cfg: bool) -> Vec<Finding> {
.collect() .collect()
} }
fn binding_suppressed(let_scope: &LetScope, declaration: NodeId, source: &str) -> bool {
let binding = let_scope
.bindings
.iter()
.find(|binding| binding.declaration == declaration)
.expect("let declaration");
is_suppressed(source, binding.range, "let_order")
}
fn binding_name(let_scope: &LetScope, declaration: NodeId) -> &str { fn binding_name(let_scope: &LetScope, declaration: NodeId) -> &str {
&let_scope &let_scope
.bindings .bindings

View File

@@ -64,6 +64,7 @@ impl ScopeGraph {
declaration: self.add_declaration(&binding.key.name, scope), declaration: self.add_declaration(&binding.key.name, scope),
name: binding.key.name.clone(), name: binding.key.name.clone(),
references: Vec::new(), references: Vec::new(),
range: binding.range,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -196,4 +197,5 @@ pub(crate) struct Binding {
pub(crate) declaration: NodeId, pub(crate) declaration: NodeId,
pub(crate) name: String, pub(crate) name: String,
pub(crate) references: Vec<NodeId>, pub(crate) references: Vec<NodeId>,
pub(crate) range: ast::SourceRange,
} }