From 11f1eba26fd997d0da7bc2608fa5bef90fc2df37 Mon Sep 17 00:00:00 2001 From: alexion Date: Fri, 18 Sep 2026 20:57:39 -0400 Subject: [PATCH] feat(xatu): add lint architecture --- Cargo.lock | 47 ++++++++++++++++++ Cargo.toml | 4 +- src/xatu/BUILD | 23 +++++++-- src/xatu/Cargo.toml | 2 + src/xatu/ast.rs | 36 ++++++++++++++ src/xatu/file_store.rs | 33 +++++++++++++ src/xatu/file_store_test.rs | 33 +++++++++++++ src/xatu/parser.rs | 84 +++++++++++++++++++++++++++++++++ src/xatu/rules.rs | 54 +++++++++++++++++++++ src/xatu/rules/empty_inherit.rs | 23 +++++++++ src/xatu/xatu.rs | 31 ++++++++---- third_party/crates/crates.bzl | 1 + 12 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 src/xatu/ast.rs create mode 100644 src/xatu/file_store.rs create mode 100644 src/xatu/file_store_test.rs create mode 100644 src/xatu/parser.rs create mode 100644 src/xatu/rules.rs create mode 100644 src/xatu/rules/empty_inherit.rs diff --git a/Cargo.lock b/Cargo.lock index a950d58..83d9f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,6 +108,18 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "heck" version = "0.5.0" @@ -164,6 +176,33 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rnix" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c163bd17372eecdf10d351c34584b7de7c1a33be4e92a32f3fb3f5a7fe3f579b" +dependencies = [ + "rowan", +] + +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown", + "rustc-hash", + "text-size", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "serde" version = "1.0.229" @@ -228,6 +267,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -261,6 +306,8 @@ version = "0.1.0" dependencies = [ "clap", "monorepo", + "rnix", + "rowan", "status", ] diff --git a/Cargo.toml b/Cargo.toml index 64d4935..bcfcb49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ resolver = "3" [workspace.dependencies] clap = "4" monorepo = { path = "src/monorepo" } +rnix = "=0.14.0" +rowan = "0.16" serde = "1.0" -status = { path = "src/status" } serde_json = "1.0" +status = { path = "src/status" } diff --git a/src/xatu/BUILD b/src/xatu/BUILD index ac03412..0bc0330 100644 --- a/src/xatu/BUILD +++ b/src/xatu/BUILD @@ -1,10 +1,27 @@ -load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library") +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") rust_library( name = "xatu_lib", crate_name = "xatu", - srcs = ["xatu.rs"], - deps = ["//src/status:status"], + srcs = [ + "ast.rs", + "file_store.rs", + "parser.rs", + "rules.rs", + "rules/empty_inherit.rs", + "xatu.rs", + ], + deps = [ + "//src/status:status", + "//third_party/crates:rnix", + "//third_party/crates/rowan-0.16.1:rowan", + ], +) + +rust_test( + name = "file_store_test", + srcs = ["file_store_test.rs"], + deps = [":xatu_lib"], ) rust_binary( diff --git a/src/xatu/Cargo.toml b/src/xatu/Cargo.toml index 10d4d0b..b9aff35 100644 --- a/src/xatu/Cargo.toml +++ b/src/xatu/Cargo.toml @@ -13,4 +13,6 @@ path = "xatu_main.rs" [dependencies] clap = { workspace = true, features = ["derive"] } monorepo = { workspace = true } +rnix = { workspace = true } +rowan = { workspace = true } status = { workspace = true } diff --git a/src/xatu/ast.rs b/src/xatu/ast.rs new file mode 100644 index 0000000..5629ef5 --- /dev/null +++ b/src/xatu/ast.rs @@ -0,0 +1,36 @@ +use crate::FileId; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Inherit { + pub attr_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NodeData { + Inherit(Inherit), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SourceRange { + pub start: usize, + pub end: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Node { + pub range: SourceRange, + pub data: NodeData, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError { + pub range: SourceRange, + pub message: String, +} + +#[derive(Debug)] +pub struct ParsedFile { + pub file_id: FileId, + pub nodes: Vec, + pub errors: Vec, +} diff --git a/src/xatu/file_store.rs b/src/xatu/file_store.rs new file mode 100644 index 0000000..f36cd14 --- /dev/null +++ b/src/xatu/file_store.rs @@ -0,0 +1,33 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FileId(usize); + +#[derive(Debug)] +pub struct SourceFile { + pub path: PathBuf, + pub source: String, +} + +#[derive(Debug, Default)] +pub struct FileStore { + files: Vec, +} + +impl FileStore { + pub fn add(&mut self, file: SourceFile) -> FileId { + let id = FileId(self.files.len()); + self.files.push(file); + id + } + + pub fn get(&self, id: FileId) -> &SourceFile { + &self.files[id.0] + } +} + +impl From> for FileStore { + fn from(files: Vec) -> Self { + Self { files } + } +} diff --git a/src/xatu/file_store_test.rs b/src/xatu/file_store_test.rs new file mode 100644 index 0000000..faa9d04 --- /dev/null +++ b/src/xatu/file_store_test.rs @@ -0,0 +1,33 @@ +use std::path::PathBuf; + +use xatu::{FileStore, SourceFile}; + +fn source_file(path: &str, source: &str) -> SourceFile { + SourceFile { + path: PathBuf::from(path), + source: source.to_owned(), + } +} + +#[test] +fn added_file_can_be_retrieved_by_id() { + let mut store = FileStore::default(); + + let id = store.add(source_file("first.nix", "{ inherit foo; }")); + let file = store.get(id); + + assert_eq!(PathBuf::from("first.nix"), file.path); + assert_eq!("{ inherit foo; }", file.source); +} + +#[test] +fn file_ids_refer_to_their_added_files() { + let mut store = FileStore::default(); + + let first = store.add(source_file("first.nix", "first")); + let second = store.add(source_file("second.nix", "second")); + + assert_ne!(first, second); + assert_eq!("first", store.get(first).source); + assert_eq!("second", store.get(second).source); +} diff --git a/src/xatu/parser.rs b/src/xatu/parser.rs new file mode 100644 index 0000000..a6a389e --- /dev/null +++ b/src/xatu/parser.rs @@ -0,0 +1,84 @@ +use rowan::ast::AstNode; + +use crate::{FileId, ast}; + +pub fn parse_file(file_id: FileId, source: &str) -> ast::ParsedFile { + let parse = rnix::Root::parse(source); + let source_len = source.len(); + + ast::ParsedFile { + file_id, + nodes: parse + .syntax() + .descendants() + .filter_map(convert_node) + .collect(), + errors: parse + .errors() + .iter() + .map(|error| convert_parse_error(error, source_len)) + .collect(), + } +} + +fn convert_parse_error(error: &rnix::ParseError, source_len: usize) -> ast::ParseError { + ast::ParseError { + range: parse_error_range(error, source_len), + message: error.to_string(), + } +} + +fn parse_error_range(error: &rnix::ParseError, source_len: usize) -> ast::SourceRange { + match error { + rnix::ParseError::Unexpected(range) + | rnix::ParseError::UnexpectedExtra(range) + | rnix::ParseError::UnexpectedWanted(_, range, _) + | rnix::ParseError::UnexpectedDoubleBind(range) + | rnix::ParseError::DuplicatedArgs(range, _) => (*range).into(), + + rnix::ParseError::UnexpectedEOF + | rnix::ParseError::UnexpectedEOFWanted(_) + | rnix::ParseError::RecursionLimitExceeded => ast::SourceRange { + start: source_len, + end: source_len, + }, + + _ => ast::SourceRange { + start: source_len, + end: source_len, + }, + } +} + +fn convert_node(node: rnix::SyntaxNode) -> Option { + match node.kind() { + rnix::SyntaxKind::NODE_INHERIT => convert_inherit(node), + _ => None, + } +} + +fn convert_inherit(node: rnix::SyntaxNode) -> Option { + let inherit = rnix::ast::Inherit::cast(node)?; + + Some(ast::Node { + range: inherit.syntax().text_range().into(), + data: ast::NodeData::Inherit(inherit.into()), + }) +} + +impl From for ast::SourceRange { + fn from(range: rnix::TextRange) -> Self { + Self { + start: usize::from(range.start()), + end: usize::from(range.end()), + } + } +} + +impl From for ast::Inherit { + fn from(inherit: rnix::ast::Inherit) -> Self { + Self { + attr_count: inherit.attrs().count(), + } + } +} diff --git a/src/xatu/rules.rs b/src/xatu/rules.rs new file mode 100644 index 0000000..09684ed --- /dev/null +++ b/src/xatu/rules.rs @@ -0,0 +1,54 @@ +use crate::FileId; +use crate::ast; +use crate::rules::empty_inherit::EmptyInherit; + +pub mod empty_inherit; + +pub struct Finding { + pub rule_id: &'static str, + pub range: ast::SourceRange, + pub message: &'static str, +} + +pub struct Diagnostic { + pub file_id: FileId, + pub finding: Finding, +} + +pub trait NodeRule { + type Node; + + const ID: &'static str; + + fn select(data: &ast::NodeData) -> Option<&Self::Node>; + fn check(node: &Self::Node) -> Option<&'static str>; +} + +fn run_node_rule(node: &ast::Node) -> Option +where + R: NodeRule, +{ + let typed_node = R::select(&node.data)?; + let message = R::check(typed_node)?; + + Some(Finding { + rule_id: R::ID, + range: node.range, + message, + }) +} + +pub fn run_rules(file: &ast::ParsedFile) -> Vec { + file.nodes + .iter() + .flat_map(|node| { + [run_node_rule::(node)] + .into_iter() + .flatten() + .map(|finding| Diagnostic { + file_id: file.file_id, + finding, + }) + }) + .collect() +} diff --git a/src/xatu/rules/empty_inherit.rs b/src/xatu/rules/empty_inherit.rs new file mode 100644 index 0000000..c044a65 --- /dev/null +++ b/src/xatu/rules/empty_inherit.rs @@ -0,0 +1,23 @@ +use crate::{ast, rules::NodeRule}; + +pub struct EmptyInherit; + +impl NodeRule for EmptyInherit { + type Node = ast::Inherit; + + const ID: &'static str = "empty_inherit"; + + fn select(data: &ast::NodeData) -> Option<&Self::Node> { + match data { + ast::NodeData::Inherit(inherit) => Some(inherit), + } + } + + fn check(node: &Self::Node) -> Option<&'static str> { + if node.attr_count == 0 { + Some("inherit statement has no attributes") + } else { + None + } + } +} diff --git a/src/xatu/xatu.rs b/src/xatu/xatu.rs index 353efa8..21fa3ad 100644 --- a/src/xatu/xatu.rs +++ b/src/xatu/xatu.rs @@ -1,14 +1,29 @@ -use std::path::PathBuf; +mod ast; +mod file_store; +mod parser; +mod rules; + +pub use file_store::{FileId, FileStore, SourceFile}; pub fn run(files: Vec) -> status::Result<()> { + let mut store = FileStore::default(); + for file in files { - println!("{}", file.path.display()); + let file_id = store.add(file); + let file = store.get(file_id); + let parsed = parser::parse_file(file_id, &file.source); + + for diagnostic in rules::run_rules(&parsed) { + println!( + "{}:{}..{}: {}: {}", + file.path.display(), + diagnostic.finding.range.start, + diagnostic.finding.range.end, + diagnostic.finding.rule_id, + diagnostic.finding.message, + ); + } } + Ok(()) } - -#[derive(Debug)] -pub struct SourceFile { - pub path: PathBuf, - pub source: String, -} diff --git a/third_party/crates/crates.bzl b/third_party/crates/crates.bzl index 9991d05..69d9612 100644 --- a/third_party/crates/crates.bzl +++ b/third_party/crates/crates.bzl @@ -340,6 +340,7 @@ _NORMAL_DEPENDENCIES = { "src/xatu": { _COMMON_CONDITION: { "clap": Label("//third_party/crates/clap-4.6.7:clap"), + "rnix": Label("//third_party/crates/rnix-0.14.0:rnix"), }, }, }