feat(xatu): add let order rule #88

Closed
alexion wants to merge 5 commits from xatu-let-order into main
10 changed files with 236 additions and 16 deletions
Showing only changes of commit d62aab7557 - Show all commits

View File

@@ -8,6 +8,7 @@ rust_library(
"file_store.rs",
"parser.rs",
"rules.rs",
"rules/let_order.rs",
"rules/manual_inherit.rs",
"rules/manual_inherit_from.rs",
"rules/unquoted_uri.rs",
@@ -32,6 +33,7 @@ rust_test(
data = [
"testdata/clean.nix",
"testdata/invalid.nix",
"testdata/let_order.nix",
"testdata/lint_findings.nix",
],
deps = [

View File

@@ -29,6 +29,7 @@ impl std::fmt::Display for SourceRange {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeData {
Binding(Binding),
LetBlock(LetBlock),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -37,6 +38,18 @@ pub struct Binding {
pub value: Expr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LetBlock {
pub bindings: Vec<LetBinding>,
pub body_references: Vec<Ident>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LetBinding {
pub name: Ident,
pub references: Vec<Ident>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ident {
pub name: String,

View File

@@ -1,4 +1,4 @@
use rnix::ast::Attr;
use rnix::ast::{Attr, HasEntry};
use rowan::ast::AstNode;
use crate::{FileId, ast};
@@ -38,6 +38,7 @@ fn convert_syntax(syntax: rnix::SyntaxNode) -> (Vec<ast::Node>, Vec<ast::Token>)
fn convert_node(node: rnix::SyntaxNode) -> Option<ast::Node> {
match node.kind() {
rnix::SyntaxKind::NODE_ATTRPATH_VALUE => convert_binding(node),
rnix::SyntaxKind::NODE_LET_IN => convert_let_block(node),
_ => None,
}
}
@@ -53,6 +54,54 @@ fn convert_binding(node: rnix::SyntaxNode) -> Option<ast::Node> {
})
}
fn convert_let_block(node: rnix::SyntaxNode) -> Option<ast::Node> {
let let_block = rnix::ast::LetIn::cast(node)?;
let range = let_block.syntax().text_range().into();
let data = let_block.try_into().ok()?;
Some(ast::Node {
range,
data: ast::NodeData::LetBlock(data),
})
}
impl TryFrom<rnix::ast::LetIn> for ast::LetBlock {
type Error = ();
fn try_from(let_block: rnix::ast::LetIn) -> Result<Self, Self::Error> {
let bindings = let_block
.attrpath_values()
.map(ast::LetBinding::try_from)
.collect::<Result<Vec<_>, _>>()?;
let body_references = let_block.body().ok_or(())?.references();
Ok(Self {
bindings,
body_references,
})
}
}
impl TryFrom<rnix::ast::AttrpathValue> for ast::LetBinding {
type Error = ();
fn try_from(binding: rnix::ast::AttrpathValue) -> Result<Self, Self::Error> {
let mut attrs = binding.attrpath().ok_or(())?.attrs();
let name = match attrs.next().ok_or(())? {
Attr::Ident(ident) => ident.into(),
_ => return Err(()),
};
if attrs.next().is_some() {
return Err(());
}
let references = binding.value().ok_or(())?.references();
Ok(Self { name, references })
}
}
impl TryFrom<rnix::ast::AttrpathValue> for ast::Binding {
type Error = ();
@@ -93,6 +142,32 @@ impl From<rnix::ast::Ident> for ast::Ident {
}
}
trait ExprReferences {
fn references(&self) -> Vec<ast::Ident>;
}
impl ExprReferences for rnix::ast::Expr {
fn references(&self) -> Vec<ast::Ident> {
self.syntax()
.descendants()
.filter_map(rnix::ast::Ident::cast)
.filter(is_reference)
.map(ast::Ident::from)
.collect()
}
}
fn is_reference(ident: &rnix::ast::Ident) -> bool {
!matches!(
ident.syntax().parent().and_then(|parent| parent.parent()),
Some(grandparent)
if matches!(
grandparent.kind(),
rnix::SyntaxKind::NODE_ATTRPATH_VALUE | rnix::SyntaxKind::NODE_SELECT
)
)
}
impl TryFrom<rnix::ast::Select> for ast::Select {
type Error = ();

View File

@@ -1,9 +1,11 @@
use crate::FileId;
use crate::ast;
use crate::rules::let_order::LetOrder;
use crate::rules::manual_inherit::ManualInherit;
use crate::rules::manual_inherit_from::ManualInheritFrom;
use crate::rules::unquoted_uri::UnquotedUri;
pub mod let_order;
pub mod manual_inherit;
pub mod manual_inherit_from;
pub mod unquoted_uri;
@@ -18,7 +20,7 @@ pub struct Diagnostic {
pub struct Finding {
pub rule_id: &'static str,
pub range: ast::SourceRange,
pub message: &'static str,
pub message: String,
}
impl std::fmt::Display for Diagnostic {
@@ -37,7 +39,7 @@ pub trait NodeRule {
const ID: &'static str;
fn select(data: &ast::NodeData) -> Option<&Self::Node>;
fn check(node: &Self::Node) -> Option<&'static str>;
fn check(node: &Self::Node) -> Option<String>;
}
pub trait TokenRule {
@@ -46,7 +48,7 @@ pub trait TokenRule {
const ID: &'static str;
fn select(data: &ast::TokenData) -> Option<&Self::Token>;
fn check(token: &Self::Token) -> Option<&'static str>;
fn check(token: &Self::Token) -> Option<String>;
}
pub fn run_rules(file: &ast::ParsedFile) -> Vec<Diagnostic> {
@@ -55,6 +57,7 @@ pub fn run_rules(file: &ast::ParsedFile) -> Vec<Diagnostic> {
.iter()
.flat_map(|node| {
[
run_node_rule::<LetOrder>(node),
run_node_rule::<ManualInherit>(node),
run_node_rule::<ManualInheritFrom>(node),
]

104
src/xatu/rules/let_order.rs Normal file
View File

@@ -0,0 +1,104 @@
use std::collections::{HashMap, HashSet};
use crate::{ast, rules::NodeRule};
pub struct LetOrder;
impl NodeRule for LetOrder {
type Node = ast::LetBlock;
const ID: &'static str = "let_order";
fn select(data: &ast::NodeData) -> Option<&Self::Node> {
match data {
ast::NodeData::LetBlock(let_block) => Some(let_block),
_ => None,
}
}
fn check(node: &Self::Node) -> Option<String> {
let expected_order = match expected_order(node) {
Some(expected_order) => expected_order,
None => return Some("Cannot order cyclic `let` bindings".to_string()),
};
let actual_order = reachable_actual_order(node, &expected_order);
if actual_order == expected_order {
return None;
}
Some(format!(
"Order reachable `let` bindings by body-first dependency flow: {}",
expected_order.join(", ")
))
}
}
fn expected_order(let_block: &ast::LetBlock) -> Option<Vec<String>> {
let bindings = let_block
.bindings
.iter()
.map(|binding| (binding.name.name.as_str(), binding))
.collect::<HashMap<_, _>>();
let mut order = Vec::new();
let mut permanent_marks = HashSet::new();
let mut temporary_marks = HashSet::new();
for reference in &let_block.body_references {
visit_binding(
&reference.name,
&bindings,
&mut order,
&mut permanent_marks,
&mut temporary_marks,
)?;
}
Some(order)
}
fn visit_binding(
name: &str,
bindings: &HashMap<&str, &ast::LetBinding>,
order: &mut Vec<String>,
permanent_marks: &mut HashSet<String>,
temporary_marks: &mut HashSet<String>,
) -> Option<()> {
let Some(binding) = bindings.get(name) else {
return Some(());
};
if permanent_marks.contains(name) {
return Some(());
}
if !temporary_marks.insert(name.to_string()) {
return None;
}
order.push(name.to_string());
for reference in &binding.references {
visit_binding(
&reference.name,
bindings,
order,
permanent_marks,
temporary_marks,
)?;
}
temporary_marks.remove(name);
permanent_marks.insert(name.to_string());
Some(())
}
fn reachable_actual_order(let_block: &ast::LetBlock, expected_order: &[String]) -> Vec<String> {
let reachable_names = expected_order.iter().collect::<HashSet<_>>();
let_block
.bindings
.iter()
.filter(|binding| reachable_names.contains(&binding.name.name))
.map(|binding| binding.name.name.clone())
.collect()
}

View File

@@ -8,15 +8,16 @@ impl NodeRule for ManualInherit {
const ID: &'static str = "manual_inherit";
fn select(data: &ast::NodeData) -> Option<&Self::Node> {
let ast::NodeData::Binding(binding) = data;
Some(binding)
match data {
ast::NodeData::Binding(binding) => Some(binding),
_ => None,
}
}
fn check(node: &Self::Node) -> Option<&'static str> {
fn check(node: &Self::Node) -> Option<String> {
match &node.value {
ast::Expr::Ident(value) if node.key == *value => {
Some("Use `inherit` for this assignment")
Some("Use `inherit` for this assignment".to_string())
}
_ => None,
}

View File

@@ -8,15 +8,16 @@ impl NodeRule for ManualInheritFrom {
const ID: &'static str = "manual_inherit_from";
fn select(data: &ast::NodeData) -> Option<&Self::Node> {
let ast::NodeData::Binding(binding) = data;
Some(binding)
match data {
ast::NodeData::Binding(binding) => Some(binding),
_ => None,
}
}
fn check(node: &Self::Node) -> Option<&'static str> {
fn check(node: &Self::Node) -> Option<String> {
match &node.value {
ast::Expr::Select(select) if node.key == select.attr => {
Some("Use `inherit` for this selected attribute")
Some("Use `inherit` for this selected attribute".to_string())
}
_ => None,
}

View File

@@ -13,7 +13,7 @@ impl TokenRule for UnquotedUri {
}
}
fn check(_token: &Self::Token) -> Option<&'static str> {
Some("URI literals should be quoted")
fn check(_token: &Self::Token) -> Option<String> {
Some("URI literals should be quoted".to_string())
}
}

4
src/xatu/testdata/let_order.nix vendored Normal file
View File

@@ -0,0 +1,4 @@
let
support = "support";
primary = support;
in primary

View File

@@ -65,6 +65,23 @@ fn xatu_reports_unquoted_uri() -> googletest::Result<()> {
Ok(())
}
#[gtest]
fn xatu_reports_let_order() -> googletest::Result<()> {
let report = xatu::future_sight(vec![fixture_source("testdata/let_order.nix")?]).or_fail()?;
let let_order_diagnostics = report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.finding.rule_id == "let_order")
.collect::<Vec<_>>();
expect_that!(let_order_diagnostics.len(), eq(1));
expect_that!(
let_order_diagnostics[0].finding.message.as_str(),
eq("Order reachable `let` bindings by body-first dependency flow: primary, support")
);
Ok(())
}
#[gtest]
fn xatu_reports_manual_inherit() -> googletest::Result<()> {
let report = xatu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?;