feat(xatu): add lint architecture

This commit was merged in pull request #71.
This commit is contained in:
2026-09-18 20:57:39 -04:00
parent d974a46211
commit 11f1eba26f
12 changed files with 359 additions and 12 deletions

47
Cargo.lock generated
View File

@@ -108,6 +108,18 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 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]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -164,6 +176,33 @@ dependencies = [
"proc-macro2", "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]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
@@ -228,6 +267,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "text-size"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.26"
@@ -261,6 +306,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"monorepo", "monorepo",
"rnix",
"rowan",
"status", "status",
] ]

View File

@@ -11,6 +11,8 @@ resolver = "3"
[workspace.dependencies] [workspace.dependencies]
clap = "4" clap = "4"
monorepo = { path = "src/monorepo" } monorepo = { path = "src/monorepo" }
rnix = "=0.14.0"
rowan = "0.16"
serde = "1.0" serde = "1.0"
status = { path = "src/status" }
serde_json = "1.0" serde_json = "1.0"
status = { path = "src/status" }

View File

@@ -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( rust_library(
name = "xatu_lib", name = "xatu_lib",
crate_name = "xatu", crate_name = "xatu",
srcs = ["xatu.rs"], srcs = [
deps = ["//src/status:status"], "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( rust_binary(

View File

@@ -13,4 +13,6 @@ path = "xatu_main.rs"
[dependencies] [dependencies]
clap = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] }
monorepo = { workspace = true } monorepo = { workspace = true }
rnix = { workspace = true }
rowan = { workspace = true }
status = { workspace = true } status = { workspace = true }

36
src/xatu/ast.rs Normal file
View File

@@ -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<Node>,
pub errors: Vec<ParseError>,
}

33
src/xatu/file_store.rs Normal file
View File

@@ -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<SourceFile>,
}
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<Vec<SourceFile>> for FileStore {
fn from(files: Vec<SourceFile>) -> Self {
Self { files }
}
}

View File

@@ -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);
}

84
src/xatu/parser.rs Normal file
View File

@@ -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<ast::Node> {
match node.kind() {
rnix::SyntaxKind::NODE_INHERIT => convert_inherit(node),
_ => None,
}
}
fn convert_inherit(node: rnix::SyntaxNode) -> Option<ast::Node> {
let inherit = rnix::ast::Inherit::cast(node)?;
Some(ast::Node {
range: inherit.syntax().text_range().into(),
data: ast::NodeData::Inherit(inherit.into()),
})
}
impl From<rnix::TextRange> for ast::SourceRange {
fn from(range: rnix::TextRange) -> Self {
Self {
start: usize::from(range.start()),
end: usize::from(range.end()),
}
}
}
impl From<rnix::ast::Inherit> for ast::Inherit {
fn from(inherit: rnix::ast::Inherit) -> Self {
Self {
attr_count: inherit.attrs().count(),
}
}
}

54
src/xatu/rules.rs Normal file
View File

@@ -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<R>(node: &ast::Node) -> Option<Finding>
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<Diagnostic> {
file.nodes
.iter()
.flat_map(|node| {
[run_node_rule::<EmptyInherit>(node)]
.into_iter()
.flatten()
.map(|finding| Diagnostic {
file_id: file.file_id,
finding,
})
})
.collect()
}

View File

@@ -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
}
}
}

View File

@@ -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<SourceFile>) -> status::Result<()> { pub fn run(files: Vec<SourceFile>) -> status::Result<()> {
let mut store = FileStore::default();
for file in files { 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(()) Ok(())
} }
#[derive(Debug)]
pub struct SourceFile {
pub path: PathBuf,
pub source: String,
}

View File

@@ -340,6 +340,7 @@ _NORMAL_DEPENDENCIES = {
"src/xatu": { "src/xatu": {
_COMMON_CONDITION: { _COMMON_CONDITION: {
"clap": Label("//third_party/crates/clap-4.6.7:clap"), "clap": Label("//third_party/crates/clap-4.6.7:clap"),
"rnix": Label("//third_party/crates/rnix-0.14.0:rnix"),
}, },
}, },
} }