From 7b09cea3af5337dd180cb12ad818d7a2d6226a8d Mon Sep 17 00:00:00 2001 From: alexion Date: Wed, 16 Sep 2026 16:03:59 -0400 Subject: [PATCH] feat(nix): add style linter scaffold --- Cargo.lock | 7 + Cargo.toml | 1 + src/nix-style-linter/BUILD | 21 +++ src/nix-style-linter/Cargo.toml | 18 +++ src/nix-style-linter/lib.rs | 231 +++++++++++++++++++++++++++++++ src/nix-style-linter/lib_test.rs | 133 ++++++++++++++++++ src/nix-style-linter/main.rs | 16 +++ third_party/crates/crates.bzl | 30 ++++ 8 files changed, 457 insertions(+) create mode 100644 src/nix-style-linter/BUILD create mode 100644 src/nix-style-linter/Cargo.toml create mode 100644 src/nix-style-linter/lib.rs create mode 100644 src/nix-style-linter/lib_test.rs create mode 100644 src/nix-style-linter/main.rs diff --git a/Cargo.lock b/Cargo.lock index cb93237..86462e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,13 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "nix-style-linter" +version = "0.1.0" +dependencies = [ + "rnix", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" diff --git a/Cargo.toml b/Cargo.toml index 10b9230..de6df26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "src/ariadne", + "src/nix-style-linter", "src/machoke", ] resolver = "3" diff --git a/src/nix-style-linter/BUILD b/src/nix-style-linter/BUILD new file mode 100644 index 0000000..31ba571 --- /dev/null +++ b/src/nix-style-linter/BUILD @@ -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"], +) diff --git a/src/nix-style-linter/Cargo.toml b/src/nix-style-linter/Cargo.toml new file mode 100644 index 0000000..5dc87c8 --- /dev/null +++ b/src/nix-style-linter/Cargo.toml @@ -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" diff --git a/src/nix-style-linter/lib.rs b/src/nix-style-linter/lib.rs new file mode 100644 index 0000000..76ebb5e --- /dev/null +++ b/src/nix-style-linter/lib.rs @@ -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 { + 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> { + let source = fs::read_to_string(path)?; + Ok(parse_diagnostics(path, &source)) +} + +fn parse_diagnostics(path: &Path, source: &str) -> Vec { + 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, 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) -> 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()) + } + } + } +} diff --git a/src/nix-style-linter/lib_test.rs b/src/nix-style-linter/lib_test.rs new file mode 100644 index 0000000..3ca553b --- /dev/null +++ b/src/nix-style-linter/lib_test.rs @@ -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) -> PathBuf { + self.root.join(relative) + } + + fn write(&self, relative: impl AsRef, 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(); + } +} diff --git a/src/nix-style-linter/main.rs b/src/nix-style-linter/main.rs new file mode 100644 index 0000000..4237fbb --- /dev/null +++ b/src/nix-style-linter/main.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; +use std::process::ExitCode; + +fn main() -> ExitCode { + let paths: Vec = 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) + } + } +} diff --git a/third_party/crates/crates.bzl b/third_party/crates/crates.bzl index 2f08651..70ede6a 100644 --- a/third_party/crates/crates.bzl +++ b/third_party/crates/crates.bzl @@ -313,6 +313,7 @@ _CRATE_EDITIONS = { "": "2018", "src/ariadne": "2024", "src/machoke": "2024", + "src/nix-style-linter": "2024", } _NORMAL_DEPENDENCIES = { @@ -330,6 +331,11 @@ _NORMAL_DEPENDENCIES = { }, "src/machoke": { }, + "src/nix-style-linter": { + _COMMON_CONDITION: { + "rnix": Label("//third_party/crates/rnix-0.14.0:rnix"), + }, + }, } _NORMAL_ALIASES = { @@ -343,6 +349,10 @@ _NORMAL_ALIASES = { }, "src/machoke": { }, + "src/nix-style-linter": { + _COMMON_CONDITION: { + }, + }, } _NORMAL_DEV_DEPENDENCIES = { @@ -352,6 +362,8 @@ _NORMAL_DEV_DEPENDENCIES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _NORMAL_DEV_ALIASES = { @@ -361,6 +373,8 @@ _NORMAL_DEV_ALIASES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _PROC_MACRO_DEPENDENCIES = { @@ -370,6 +384,8 @@ _PROC_MACRO_DEPENDENCIES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _PROC_MACRO_ALIASES = { @@ -379,6 +395,8 @@ _PROC_MACRO_ALIASES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _PROC_MACRO_DEV_DEPENDENCIES = { @@ -388,6 +406,8 @@ _PROC_MACRO_DEV_DEPENDENCIES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _PROC_MACRO_DEV_ALIASES = { @@ -397,6 +417,8 @@ _PROC_MACRO_DEV_ALIASES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _BUILD_DEPENDENCIES = { @@ -406,6 +428,8 @@ _BUILD_DEPENDENCIES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _BUILD_ALIASES = { @@ -415,6 +439,8 @@ _BUILD_ALIASES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _BUILD_PROC_MACRO_DEPENDENCIES = { @@ -424,6 +450,8 @@ _BUILD_PROC_MACRO_DEPENDENCIES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _BUILD_PROC_MACRO_ALIASES = { @@ -433,6 +461,8 @@ _BUILD_PROC_MACRO_ALIASES = { }, "src/machoke": { }, + "src/nix-style-linter": { + }, } _CONDITIONS = {