diff --git a/src/xatu/BUILD b/src/xatu/BUILD index 4a72c08..f60733b 100644 --- a/src/xatu/BUILD +++ b/src/xatu/BUILD @@ -8,6 +8,8 @@ rust_library( "file_store.rs", "parser.rs", "rules.rs", + "rules/manual_inherit.rs", + "rules/manual_inherit_from.rs", "rules/unquoted_uri.rs", "xatu.rs", ], diff --git a/src/xatu/ast.rs b/src/xatu/ast.rs index 1addbf4..3b7fa50 100644 --- a/src/xatu/ast.rs +++ b/src/xatu/ast.rs @@ -29,6 +29,7 @@ impl std::fmt::Display for SourceRange { #[derive(Debug, Clone, PartialEq, Eq)] pub enum NodeData { Apply(Apply), + Assignment(Assignment), LetIn(LetIn), Inherit(Inherit), } @@ -38,6 +39,12 @@ pub struct Apply { pub function: Expr, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Assignment { + pub name: Ident, + pub value: Expr, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Expr { Ident(Ident), diff --git a/src/xatu/parser.rs b/src/xatu/parser.rs index e92f605..245605b 100644 --- a/src/xatu/parser.rs +++ b/src/xatu/parser.rs @@ -38,6 +38,7 @@ fn convert_syntax(syntax: rnix::SyntaxNode) -> (Vec, Vec) fn convert_node(node: rnix::SyntaxNode) -> Option { match node.kind() { rnix::SyntaxKind::NODE_APPLY => convert_apply(node), + rnix::SyntaxKind::NODE_ATTRPATH_VALUE => convert_assignment(node), rnix::SyntaxKind::NODE_LET_IN => convert_let_in(node), rnix::SyntaxKind::NODE_INHERIT => convert_inherit(node), _ => None, @@ -65,6 +66,41 @@ impl TryFrom for ast::Apply { } } +fn convert_assignment(node: rnix::SyntaxNode) -> Option { + let assignment = rnix::ast::AttrpathValue::cast(node)?; + let range = assignment.syntax().text_range().into(); + let data = assignment.try_into().ok()?; + + Some(ast::Node { + range, + data: ast::NodeData::Assignment(data), + }) +} + +impl TryFrom for ast::Assignment { + type Error = (); + + fn try_from(assignment: rnix::ast::AttrpathValue) -> Result { + let mut attrs = assignment.attrpath().ok_or(())?.attrs(); + let rnix::ast::Attr::Ident(name) = attrs.next().ok_or(())? else { + return Err(()); + }; + if attrs.next().is_some() { + return Err(()); + } + + let value = assignment.value().ok_or(())?; + if matches!(&value, rnix::ast::Expr::Select(select) if select.or_token().is_some()) { + return Err(()); + } + + Ok(Self { + name: name.into(), + value: value.try_into()?, + }) + } +} + impl TryFrom for ast::Expr { type Error = (); diff --git a/src/xatu/rules.rs b/src/xatu/rules.rs index 45af16e..b27570d 100644 --- a/src/xatu/rules.rs +++ b/src/xatu/rules.rs @@ -1,7 +1,11 @@ use crate::FileId; use crate::ast; +use crate::rules::manual_inherit::ManualInherit; +use crate::rules::manual_inherit_from::ManualInheritFrom; use crate::rules::unquoted_uri::UnquotedUri; +pub mod manual_inherit; +pub mod manual_inherit_from; pub mod unquoted_uri; #[derive(Debug)] @@ -27,7 +31,6 @@ impl std::fmt::Display for Diagnostic { } } -#[expect(dead_code, reason = "retained for planned node rules")] pub trait NodeRule { type Node; @@ -47,7 +50,16 @@ pub trait TokenRule { } pub fn run_rules(file: &ast::ParsedFile) -> Vec { - let node_diagnostics = file.nodes.iter().flat_map(|_| std::iter::empty()); + let node_diagnostics = file + .nodes + .iter() + .flat_map(|node| { + [ + run_node_rule::(node), + run_node_rule::(node), + ] + }) + .flatten(); let token_diagnostics = file .tokens .iter() @@ -63,7 +75,6 @@ pub fn run_rules(file: &ast::ParsedFile) -> Vec { .collect() } -#[expect(dead_code, reason = "retained for planned node rules")] fn run_node_rule(node: &ast::Node) -> Option where R: NodeRule, diff --git a/src/xatu/rules/manual_inherit.rs b/src/xatu/rules/manual_inherit.rs new file mode 100644 index 0000000..3c6f1e7 --- /dev/null +++ b/src/xatu/rules/manual_inherit.rs @@ -0,0 +1,24 @@ +use crate::{ast, rules::NodeRule}; + +pub struct ManualInherit; + +impl NodeRule for ManualInherit { + type Node = ast::Assignment; + + const ID: &'static str = "manual_inherit"; + + fn select(data: &ast::NodeData) -> Option<&Self::Node> { + match data { + ast::NodeData::Assignment(assignment) => Some(assignment), + _ => None, + } + } + + fn check(assignment: &Self::Node) -> Option<&'static str> { + let ast::Expr::Ident(value) = &assignment.value else { + return None; + }; + + (assignment.name == *value).then_some("Use `inherit` for this assignment") + } +} diff --git a/src/xatu/rules/manual_inherit_from.rs b/src/xatu/rules/manual_inherit_from.rs new file mode 100644 index 0000000..4023a39 --- /dev/null +++ b/src/xatu/rules/manual_inherit_from.rs @@ -0,0 +1,30 @@ +use crate::{ast, rules::NodeRule}; + +pub struct ManualInheritFrom; + +impl NodeRule for ManualInheritFrom { + type Node = ast::Assignment; + + const ID: &'static str = "manual_inherit_from"; + + fn select(data: &ast::NodeData) -> Option<&Self::Node> { + match data { + ast::NodeData::Assignment(assignment) => Some(assignment), + _ => None, + } + } + + fn check(assignment: &Self::Node) -> Option<&'static str> { + let ast::Expr::Select(select) = &assignment.value else { + return None; + }; + let ast::Expr::Ident(_) = &select.receiver else { + return None; + }; + let [selected] = select.attrs.as_slice() else { + return None; + }; + + (assignment.name == *selected).then_some("Use `inherit (source)` for this assignment") + } +} diff --git a/src/xatu/testdata/lint_findings.nix b/src/xatu/testdata/lint_findings.nix index 1b922e8..e777e7e 100644 --- a/src/xatu/testdata/lint_findings.nix +++ b/src/xatu/testdata/lint_findings.nix @@ -1,3 +1,5 @@ { + package = package; + sourcePackage = source.sourcePackage; url = https://example.com; } diff --git a/src/xatu/xatu_test.rs b/src/xatu/xatu_test.rs index 57ce7f4..e6feb6e 100644 --- a/src/xatu/xatu_test.rs +++ b/src/xatu/xatu_test.rs @@ -41,6 +41,44 @@ fn xatu_rejects_invalid_source() -> googletest::Result<()> { Ok(()) } +#[gtest] +fn xatu_reports_manual_inherit() -> googletest::Result<()> { + let report = xatu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?; + + let findings = report + .diagnostics + .iter() + .map(|diagnostic| diagnostic.finding.rule_id) + .collect::>(); + expect_that!(findings, contains(eq(&"manual_inherit"))); + Ok(()) +} + +#[gtest] +fn xatu_reports_manual_inherit_from() -> googletest::Result<()> { + let report = xatu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?; + + let findings = report + .diagnostics + .iter() + .map(|diagnostic| diagnostic.finding.rule_id) + .collect::>(); + expect_that!(findings, contains(eq(&"manual_inherit_from"))); + Ok(()) +} + +#[gtest] +fn xatu_ignores_other_assignment_forms() -> googletest::Result<()> { + let report = xatu::future_sight(vec![SourceFile { + path: PathBuf::from("other_assignment_forms.nix"), + source: "{ nested = source.group.nested; target.path = path; defaulted = source.defaulted or null; renamed = source.original; }".into(), + }]) + .or_fail()?; + + expect_that!(report.is_clean(), is_true()); + Ok(()) +} + #[gtest] fn xatu_reports_unquoted_uri() -> googletest::Result<()> { let report = xatu::future_sight(vec![fixture_source("testdata/lint_findings.nix")?]).or_fail()?;