From 5cb2de25f0a377a75662cc32c9604bbfe3db3377 Mon Sep 17 00:00:00 2001 From: alexion Date: Thu, 17 Sep 2026 00:17:22 -0400 Subject: [PATCH] feat(xatu): scaffold Nix linter Add the new Xatu Rust package and Bazel targets as the foundation for a general Nix linter. The initial command parses explicit Nix file paths and reports rnix parse diagnostics in compiler-style output. --- Cargo.lock | 39 +++++------ Cargo.toml | 1 + src/xatu/BUILD | 24 +++++++ src/xatu/Cargo.toml | 19 ++++++ src/xatu/lib.rs | 152 +++++++++++++++++++++++++++++++++++++++++++ src/xatu/lib_test.rs | 32 +++++++++ src/xatu/main.rs | 35 ++++++++++ 7 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 src/xatu/BUILD create mode 100644 src/xatu/Cargo.toml create mode 100644 src/xatu/lib.rs create mode 100644 src/xatu/lib_test.rs create mode 100644 src/xatu/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1375ba1..de37464 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -73,9 +73,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -85,9 +85,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", @@ -97,9 +97,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "colorchoice" @@ -113,13 +113,6 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" -[[package]] -name = "direct-cargo-bazel-deps" -version = "0.0.1" -dependencies = [ - "rnix", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -264,9 +257,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -281,9 +274,9 @@ checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "ab72a15cf68d77cb0987d3684aa8a45c5ef827e8cb49ee2f30bfd7ba2feb519f" [[package]] name = "utf8parse" @@ -306,6 +299,14 @@ dependencies = [ "windows-link", ] +[[package]] +name = "xatu" +version = "0.1.0" +dependencies = [ + "clap", + "rnix", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index de6df26..2945153 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "src/ariadne", "src/nix-style-linter", + "src/xatu", "src/machoke", ] resolver = "3" diff --git a/src/xatu/BUILD b/src/xatu/BUILD new file mode 100644 index 0000000..5ad8125 --- /dev/null +++ b/src/xatu/BUILD @@ -0,0 +1,24 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") + +rust_library( + name = "xatu_lib", + crate_name = "xatu", + srcs = ["lib.rs"], + deps = ["//third_party/crates:rnix"], +) + +rust_binary( + name = "xatu", + crate_name = "xatu_cli", + srcs = ["main.rs"], + deps = [ + ":xatu_lib", + "//third_party/crates:clap", + ], +) + +rust_test( + name = "lib_test", + srcs = ["lib_test.rs"], + deps = [":xatu_lib"], +) diff --git a/src/xatu/Cargo.toml b/src/xatu/Cargo.toml new file mode 100644 index 0000000..bf72d7e --- /dev/null +++ b/src/xatu/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "xatu" +version = "0.1.0" +edition = "2024" + +[lib] +path = "lib.rs" + +[[bin]] +name = "xatu" +path = "main.rs" + +[[test]] +name = "lib_test" +path = "lib_test.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +rnix = "0.14.0" diff --git a/src/xatu/lib.rs b/src/xatu/lib.rs new file mode 100644 index 0000000..5788637 --- /dev/null +++ b/src/xatu/lib.rs @@ -0,0 +1,152 @@ +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use rnix::ParseError; +pub type Result = std::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + pub paths: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct CheckReport { + diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + pub path: PathBuf, + pub line: usize, + pub column: usize, + pub rule: Rule, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Rule { + ParseError, +} + +#[derive(Debug)] +pub enum Error { + Read { path: PathBuf, source: io::Error }, +} + +impl CheckReport { + pub fn diagnostics(&self) -> &[Diagnostic] { + &self.diagnostics + } + + pub fn has_diagnostics(&self) -> bool { + !self.diagnostics.is_empty() + } +} + +pub fn check(config: &Config) -> Result { + let mut report = CheckReport::default(); + + for path in &config.paths { + check_file(path, &mut report)?; + } + + Ok(report) +} + +pub fn check_source(path: impl Into, source: &str) -> CheckReport { + let path = path.into(); + let parsed = rnix::Root::parse(source); + let diagnostics = parsed + .errors() + .iter() + .map(|error| parse_diagnostic(&path, source, error)) + .collect(); + + CheckReport { diagnostics } +} + +fn check_file(path: &Path, report: &mut CheckReport) -> Result<()> { + let source = fs::read_to_string(path).map_err(|source| Error::Read { + path: path.to_path_buf(), + source, + })?; + + report + .diagnostics + .extend(check_source(path, &source).diagnostics); + Ok(()) +} + +fn parse_diagnostic(path: &Path, source: &str, error: &ParseError) -> Diagnostic { + let offset = parse_error_offset(error).unwrap_or(source.len()); + let (line, column) = line_column(source, offset); + + Diagnostic { + path: path.to_path_buf(), + line, + column, + rule: Rule::ParseError, + message: error.to_string(), + } +} + +fn parse_error_offset(error: &ParseError) -> Option { + match error { + ParseError::Unexpected(range) + | ParseError::UnexpectedExtra(range) + | ParseError::UnexpectedWanted(_, range, _) + | ParseError::UnexpectedDoubleBind(range) + | ParseError::DuplicatedArgs(range, _) => Some(range.start().into()), + ParseError::UnexpectedEOF + | ParseError::UnexpectedEOFWanted(_) + | ParseError::RecursionLimitExceeded => None, + _ => None, + } +} + +fn line_column(source: &str, offset: usize) -> (usize, usize) { + let bounded_offset = offset.min(source.len()); + let before = &source[..bounded_offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rfind('\n') + .map_or(bounded_offset + 1, |newline| bounded_offset - newline); + + (line, column) +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{}:{}:{}: {}: {}", + self.path.display(), + self.line, + self.column, + self.rule, + self.message + ) + } +} + +impl fmt::Display for Rule { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Rule::ParseError => formatter.write_str("parse-error"), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Read { path, source } => { + write!(formatter, "failed to read {}: {source}", path.display()) + } + } + } +} + +impl std::error::Error for Error {} diff --git a/src/xatu/lib_test.rs b/src/xatu/lib_test.rs new file mode 100644 index 0000000..607f438 --- /dev/null +++ b/src/xatu/lib_test.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; + +use xatu::{check_source, Rule}; + +#[test] +fn valid_source_has_no_diagnostics() { + let report = check_source("valid.nix", "{ value = 1; }\n"); + + assert!(report.diagnostics().is_empty()); +} + +#[test] +fn invalid_source_reports_parse_error() { + let report = check_source("invalid.nix", "{ value = ; }\n"); + + assert!(!report.diagnostics().is_empty()); + let diagnostic = &report.diagnostics()[0]; + + assert_eq!(diagnostic.path, PathBuf::from("invalid.nix")); + assert_eq!(diagnostic.line, 1); + assert_eq!(diagnostic.rule, Rule::ParseError); + assert!(diagnostic.message.contains("unexpected")); +} + +#[test] +fn display_uses_compiler_style_shape() { + let report = check_source("invalid.nix", "{ value = ; }\n"); + let diagnostic = &report.diagnostics()[0]; + + assert!(diagnostic.to_string().starts_with("invalid.nix:1:")); + assert!(diagnostic.to_string().contains(": parse-error: ")); +} diff --git a/src/xatu/main.rs b/src/xatu/main.rs new file mode 100644 index 0000000..38282a0 --- /dev/null +++ b/src/xatu/main.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Debug, Parser)] +struct Args { + #[arg(required = true)] + paths: Vec, +} + +impl From for xatu::Config { + fn from(args: Args) -> Self { + xatu::Config { paths: args.paths } + } +} + +fn main() { + let config = xatu::Config::from(Args::parse()); + + match xatu::check(&config) { + Ok(report) => { + for diagnostic in report.diagnostics() { + println!("{diagnostic}"); + } + + if report.has_diagnostics() { + std::process::exit(1); + } + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } + } +}