feat(nix): add style linter scaffold #58

Merged
alexion merged 1 commits from feat/nix-style-linter-scaffold into main 2026-09-16 16:06:39 -04:00
8 changed files with 457 additions and 0 deletions
Showing only changes of commit 7b09cea3af - Show all commits

7
Cargo.lock generated
View File

@@ -154,6 +154,13 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nix-style-linter"
version = "0.1.0"
dependencies = [
"rnix",
]
[[package]] [[package]]
name = "once_cell_polyfill" name = "once_cell_polyfill"
version = "1.70.2" version = "1.70.2"

View File

@@ -1,6 +1,7 @@
[workspace] [workspace]
members = [ members = [
"src/ariadne", "src/ariadne",
"src/nix-style-linter",
"src/machoke", "src/machoke",
] ]
resolver = "3" resolver = "3"

View File

@@ -0,0 +1,21 @@
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test")
rust_library(
name = "nix_style_linter_lib",
crate_name = "nix_style_linter",
srcs = ["lib.rs"],
deps = ["//third_party/crates:rnix"],
)
rust_binary(
name = "nix-style-linter",
crate_name = "nix_style_linter_cli",
srcs = ["main.rs"],
deps = [":nix_style_linter_lib"],
)
rust_test(
name = "lib_test",
srcs = ["lib_test.rs"],
deps = [":nix_style_linter_lib"],
)

View File

@@ -0,0 +1,18 @@
[package]
name = "nix-style-linter"
version = "0.1.0"
edition = "2024"
[lib]
path = "lib.rs"
[[bin]]
name = "nix-style-linter"
path = "main.rs"
[[test]]
name = "lib_test"
path = "lib_test.rs"
[dependencies]
rnix = "0.14.0"

231
src/nix-style-linter/lib.rs Normal file
View File

@@ -0,0 +1,231 @@
use std::ffi::OsStr;
use std::fmt;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunStatus {
Clean,
Diagnostics,
ToolError,
}
impl RunStatus {
pub fn code(self) -> u8 {
match self {
RunStatus::Clean => 0,
RunStatus::Diagnostics => 1,
RunStatus::ToolError => 2,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub path: PathBuf,
pub line: usize,
pub column: usize,
pub rule: &'static str,
pub message: String,
}
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
)
}
}
pub fn run(
paths: &[PathBuf],
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> io::Result<RunStatus> {
if paths.is_empty() {
writeln!(stderr, "error: expected at least one path")?;
return Ok(RunStatus::ToolError);
}
let mut found_diagnostics = false;
let mut found_tool_errors = false;
for path in paths {
match collect_nix_files(path) {
Ok(files) => {
for file in files {
match lint_file(&file) {
Ok(diagnostics) => {
for diagnostic in diagnostics {
found_diagnostics = true;
writeln!(stdout, "{diagnostic}")?;
}
}
Err(error) => {
found_tool_errors = true;
writeln!(
stderr,
"error: failed to read {path}: {error}",
path = file.display()
)?;
}
}
}
}
Err(error) => {
found_tool_errors = true;
writeln!(stderr, "error: {error}")?;
}
}
}
if found_tool_errors {
Ok(RunStatus::ToolError)
} else if found_diagnostics {
Ok(RunStatus::Diagnostics)
} else {
Ok(RunStatus::Clean)
}
}
pub fn lint_file(path: &Path) -> io::Result<Vec<Diagnostic>> {
let source = fs::read_to_string(path)?;
Ok(parse_diagnostics(path, &source))
}
fn parse_diagnostics(path: &Path, source: &str) -> Vec<Diagnostic> {
let parse = rnix::Root::parse(source);
parse
.errors()
.iter()
.map(|error| {
let (line, column) = line_column(source, parse_error_offset(error, source.len()));
Diagnostic {
path: path.to_path_buf(),
line,
column,
rule: "parse-error",
message: error.to_string(),
}
})
.collect()
}
fn collect_nix_files(path: &Path) -> Result<Vec<PathBuf>, InputError> {
let metadata = fs::metadata(path).map_err(|source| InputError::Metadata {
path: path.to_path_buf(),
source,
})?;
if metadata.is_file() {
return Ok(is_nix_file(path)
.then(|| path.to_path_buf())
.into_iter()
.collect());
}
if metadata.is_dir() {
let mut files = Vec::new();
collect_nix_files_from_dir(path, &mut files)?;
files.sort();
return Ok(files);
}
Err(InputError::UnsupportedPath(path.to_path_buf()))
}
fn collect_nix_files_from_dir(path: &Path, files: &mut Vec<PathBuf>) -> Result<(), InputError> {
let entries = fs::read_dir(path).map_err(|source| InputError::ReadDir {
path: path.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| InputError::ReadDir {
path: path.to_path_buf(),
source,
})?;
let entry_path = entry.path();
let file_type = entry.file_type().map_err(|source| InputError::Metadata {
path: entry_path.clone(),
source,
})?;
if file_type.is_dir() {
collect_nix_files_from_dir(&entry_path, files)?;
} else if file_type.is_file() && is_nix_file(&entry_path) {
files.push(entry_path);
}
}
Ok(())
}
fn is_nix_file(path: &Path) -> bool {
path.extension() == Some(OsStr::new("nix"))
}
fn parse_error_offset(error: &rnix::ParseError, end_offset: usize) -> usize {
match error {
rnix::ParseError::Unexpected(range)
| rnix::ParseError::UnexpectedExtra(range)
| rnix::ParseError::UnexpectedWanted(_, range, _)
| rnix::ParseError::UnexpectedDoubleBind(range)
| rnix::ParseError::DuplicatedArgs(range, _) => usize::from(range.start()),
rnix::ParseError::UnexpectedEOF
| rnix::ParseError::UnexpectedEOFWanted(_)
| rnix::ParseError::RecursionLimitExceeded => end_offset,
_ => end_offset,
}
}
fn line_column(source: &str, offset: usize) -> (usize, usize) {
let target = offset.min(source.len());
let mut line = 1;
let mut column = 1;
for (index, character) in source.char_indices() {
if index >= target {
break;
}
if character == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
(line, column)
}
#[derive(Debug)]
enum InputError {
Metadata { path: PathBuf, source: io::Error },
ReadDir { path: PathBuf, source: io::Error },
UnsupportedPath(PathBuf),
}
impl fmt::Display for InputError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InputError::Metadata { path, source } => {
write!(formatter, "failed to inspect {}: {source}", path.display())
}
InputError::ReadDir { path, source } => {
write!(formatter, "failed to read directory {}: {source}", path.display())
}
InputError::UnsupportedPath(path) => {
write!(formatter, "unsupported path type: {}", path.display())
}
}
}
}

View File

@@ -0,0 +1,133 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use nix_style_linter::RunStatus;
static TEST_DIRECTORY_ID: AtomicUsize = AtomicUsize::new(0);
#[test]
fn malformed_file_reports_parse_error() {
let workspace = TestWorkspace::new("malformed_file_reports_parse_error");
let path = workspace.write("broken.nix", "let x = ; in x\n");
let output = run_linter(&[path]);
assert_eq!(output.status, RunStatus::Diagnostics);
assert_eq!(output.stderr, "");
assert!(output.stdout.starts_with(&format!(
"{}:1:9: parse-error: unexpected TOKEN_SEMICOLON at 8..9",
workspace.path("broken.nix").display()
)));
}
#[test]
fn valid_file_exits_clean() {
let workspace = TestWorkspace::new("valid_file_exits_clean");
let path = workspace.write("valid.nix", "let x = 1; in x\n");
let output = run_linter(&[path]);
assert_eq!(output.status, RunStatus::Clean);
assert_eq!(output.stdout, "");
assert_eq!(output.stderr, "");
}
#[test]
fn missing_path_reports_tool_error() {
let workspace = TestWorkspace::new("missing_path_reports_tool_error");
let path = workspace.path("missing.nix");
let output = run_linter(&[path.clone()]);
assert_eq!(output.status, RunStatus::ToolError);
assert_eq!(output.stdout, "");
assert!(output.stderr.starts_with(&format!(
"error: failed to inspect {}:",
path.display()
)));
}
#[test]
fn directory_inputs_recurse_through_nix_files() {
let workspace = TestWorkspace::new("directory_inputs_recurse_through_nix_files");
workspace.write("ignored.txt", "let x = ; in x\n");
workspace.write("nested/broken.nix", "let\n x = 1;\n y = ;\nin y\n");
let output = run_linter(&[workspace.root.clone()]);
assert_eq!(output.status, RunStatus::Diagnostics);
assert_eq!(output.stderr, "");
assert!(output.stdout.starts_with(&format!(
"{}:3:7: parse-error: unexpected TOKEN_SEMICOLON at 19..20",
workspace.path("nested/broken.nix").display()
)));
assert!(!output.stdout.contains("ignored.txt"));
}
#[test]
fn empty_invocation_reports_tool_error() {
let output = run_linter(&[]);
assert_eq!(output.status, RunStatus::ToolError);
assert_eq!(output.stdout, "");
assert_eq!(output.stderr, "error: expected at least one path\n");
}
struct LinterOutput {
status: RunStatus,
stdout: String,
stderr: String,
}
fn run_linter(paths: &[PathBuf]) -> LinterOutput {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let status = nix_style_linter::run(paths, &mut stdout, &mut stderr).unwrap();
LinterOutput {
status,
stdout: String::from_utf8(stdout).unwrap(),
stderr: String::from_utf8(stderr).unwrap(),
}
}
struct TestWorkspace {
root: PathBuf,
}
impl TestWorkspace {
fn new(name: &str) -> Self {
let id = TEST_DIRECTORY_ID.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"nix-style-linter-{process}-{name}-{id}",
process = std::process::id()
));
if root.exists() {
fs::remove_dir_all(&root).unwrap();
}
fs::create_dir_all(&root).unwrap();
Self { root }
}
fn path(&self, relative: impl AsRef<Path>) -> PathBuf {
self.root.join(relative)
}
fn write(&self, relative: impl AsRef<Path>, contents: &str) -> PathBuf {
let path = self.path(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, contents).unwrap();
path
}
}
impl Drop for TestWorkspace {
fn drop(&mut self) {
fs::remove_dir_all(&self.root).unwrap();
}
}

View File

@@ -0,0 +1,16 @@
use std::path::PathBuf;
use std::process::ExitCode;
fn main() -> ExitCode {
let paths: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let mut stdout = std::io::stdout().lock();
let mut stderr = std::io::stderr().lock();
match nix_style_linter::run(&paths, &mut stdout, &mut stderr) {
Ok(status) => ExitCode::from(status.code()),
Err(error) => {
eprintln!("error: internal error: {error}");
ExitCode::from(2)
}
}
}

View File

@@ -313,6 +313,7 @@ _CRATE_EDITIONS = {
"": "2018", "": "2018",
"src/ariadne": "2024", "src/ariadne": "2024",
"src/machoke": "2024", "src/machoke": "2024",
"src/nix-style-linter": "2024",
} }
_NORMAL_DEPENDENCIES = { _NORMAL_DEPENDENCIES = {
@@ -330,6 +331,11 @@ _NORMAL_DEPENDENCIES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
_COMMON_CONDITION: {
"rnix": Label("//third_party/crates/rnix-0.14.0:rnix"),
},
},
} }
_NORMAL_ALIASES = { _NORMAL_ALIASES = {
@@ -343,6 +349,10 @@ _NORMAL_ALIASES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
_COMMON_CONDITION: {
},
},
} }
_NORMAL_DEV_DEPENDENCIES = { _NORMAL_DEV_DEPENDENCIES = {
@@ -352,6 +362,8 @@ _NORMAL_DEV_DEPENDENCIES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_NORMAL_DEV_ALIASES = { _NORMAL_DEV_ALIASES = {
@@ -361,6 +373,8 @@ _NORMAL_DEV_ALIASES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_PROC_MACRO_DEPENDENCIES = { _PROC_MACRO_DEPENDENCIES = {
@@ -370,6 +384,8 @@ _PROC_MACRO_DEPENDENCIES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_PROC_MACRO_ALIASES = { _PROC_MACRO_ALIASES = {
@@ -379,6 +395,8 @@ _PROC_MACRO_ALIASES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_PROC_MACRO_DEV_DEPENDENCIES = { _PROC_MACRO_DEV_DEPENDENCIES = {
@@ -388,6 +406,8 @@ _PROC_MACRO_DEV_DEPENDENCIES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_PROC_MACRO_DEV_ALIASES = { _PROC_MACRO_DEV_ALIASES = {
@@ -397,6 +417,8 @@ _PROC_MACRO_DEV_ALIASES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_BUILD_DEPENDENCIES = { _BUILD_DEPENDENCIES = {
@@ -406,6 +428,8 @@ _BUILD_DEPENDENCIES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_BUILD_ALIASES = { _BUILD_ALIASES = {
@@ -415,6 +439,8 @@ _BUILD_ALIASES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_BUILD_PROC_MACRO_DEPENDENCIES = { _BUILD_PROC_MACRO_DEPENDENCIES = {
@@ -424,6 +450,8 @@ _BUILD_PROC_MACRO_DEPENDENCIES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_BUILD_PROC_MACRO_ALIASES = { _BUILD_PROC_MACRO_ALIASES = {
@@ -433,6 +461,8 @@ _BUILD_PROC_MACRO_ALIASES = {
}, },
"src/machoke": { "src/machoke": {
}, },
"src/nix-style-linter": {
},
} }
_CONDITIONS = { _CONDITIONS = {