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.
This commit is contained in:
39
Cargo.lock
generated
39
Cargo.lock
generated
@@ -63,9 +63,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.6"
|
version = "4.6.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -73,9 +73,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.6.6"
|
version = "4.6.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -85,9 +85,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.4"
|
version = "4.6.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -97,9 +97,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_lex"
|
name = "clap_lex"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
@@ -113,13 +113,6 @@ version = "3.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "direct-cargo-bazel-deps"
|
|
||||||
version = "0.0.1"
|
|
||||||
dependencies = [
|
|
||||||
"rnix",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
@@ -264,9 +257,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.5"
|
version = "3.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -281,9 +274,9 @@ checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.25"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "ab72a15cf68d77cb0987d3684aa8a45c5ef827e8cb49ee2f30bfd7ba2feb519f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
@@ -306,6 +299,14 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xatu"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"rnix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
members = [
|
members = [
|
||||||
"src/ariadne",
|
"src/ariadne",
|
||||||
"src/nix-style-linter",
|
"src/nix-style-linter",
|
||||||
|
"src/xatu",
|
||||||
"src/machoke",
|
"src/machoke",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|||||||
24
src/xatu/BUILD
Normal file
24
src/xatu/BUILD
Normal file
@@ -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"],
|
||||||
|
)
|
||||||
19
src/xatu/Cargo.toml
Normal file
19
src/xatu/Cargo.toml
Normal file
@@ -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"
|
||||||
152
src/xatu/lib.rs
Normal file
152
src/xatu/lib.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use rnix::ParseError;
|
||||||
|
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Config {
|
||||||
|
pub paths: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CheckReport {
|
||||||
|
diagnostics: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<CheckReport> {
|
||||||
|
let mut report = CheckReport::default();
|
||||||
|
|
||||||
|
for path in &config.paths {
|
||||||
|
check_file(path, &mut report)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_source(path: impl Into<PathBuf>, 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<usize> {
|
||||||
|
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 {}
|
||||||
32
src/xatu/lib_test.rs
Normal file
32
src/xatu/lib_test.rs
Normal file
@@ -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: "));
|
||||||
|
}
|
||||||
35
src/xatu/main.rs
Normal file
35
src/xatu/main.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
struct Args {
|
||||||
|
#[arg(required = true)]
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Args> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user