diff --git a/Cargo.lock b/Cargo.lock index fec6a7d..2a178f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,10 @@ dependencies = [ [[package]] name = "xatu" version = "0.1.0" +dependencies = [ + "rnix", + "rowan", +] [[package]] name = "zmij" diff --git a/src/xatu/BUILD b/src/xatu/BUILD index c51e802..0be7aee 100644 --- a/src/xatu/BUILD +++ b/src/xatu/BUILD @@ -4,6 +4,10 @@ rust_library( name = "xatu_lib", crate_name = "xatu", srcs = ["lib.rs"], + deps = [ + "//third_party/crates:rnix", + "//third_party/crates:rowan", + ], ) rust_binary( diff --git a/src/xatu/Cargo.toml b/src/xatu/Cargo.toml index 1989b10..34444d1 100644 --- a/src/xatu/Cargo.toml +++ b/src/xatu/Cargo.toml @@ -11,3 +11,5 @@ name = "xatu" path = "main.rs" [dependencies] +rnix = "0.14.0" +rowan = "0.16.1" diff --git a/src/xatu/lib.rs b/src/xatu/lib.rs index e3fa985..be00d6d 100644 --- a/src/xatu/lib.rs +++ b/src/xatu/lib.rs @@ -1,5 +1,295 @@ -pub const GREETING: &str = "Hello from Xatu"; +use std::fmt; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; -pub fn greeting() -> &'static str { - GREETING +use rnix::ast; +use rowan::ast::AstNode; + +const EMPTY_INHERIT_RULE: &str = "empty_inherit"; +const PARSE_ERROR_RULE: &str = "parse_error"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunMode { + Check, + Fix, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunStatus { + Clean, + Findings, +} + +impl RunStatus { + pub fn code(self) -> u8 { + match self { + RunStatus::Clean => 0, + RunStatus::Findings => 1, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + pub path: PathBuf, + pub rule: &'static str, + pub message: String, + pub line: usize, + pub column: usize, +} + +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], + mode: RunMode, + stdout: &mut impl Write, + stderr: &mut impl Write, +) -> io::Result { + if paths.is_empty() { + writeln!(stderr, "error: provide at least one file or directory")?; + return Ok(RunStatus::Findings); + } + + let mut status = RunStatus::Clean; + + for input in paths { + let files = match collect_nix_files(input) { + Ok(files) => files, + Err(error) => { + writeln!(stderr, "error: {error}")?; + status = RunStatus::Findings; + continue; + } + }; + + for file in files { + let source = match fs::read_to_string(&file) { + Ok(source) => source, + Err(error) => { + writeln!(stderr, "error: {}: {error}", file.display())?; + status = RunStatus::Findings; + continue; + } + }; + + let outcome = analyze_source(&file, &source); + + for diagnostic in &outcome.diagnostics { + writeln!(stdout, "{diagnostic}")?; + } + + if !outcome.diagnostics.is_empty() { + status = RunStatus::Findings; + } + + if mode == RunMode::Fix && !outcome.edits.is_empty() { + let fixed = apply_edits(&source, &outcome.edits); + fs::write(&file, fixed)?; + } + } + } + + Ok(status) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AnalysisOutcome { + diagnostics: Vec, + edits: Vec, +} + +pub fn diagnostics_for_source(path: &Path, source: &str) -> Vec { + analyze_source(path, source).diagnostics +} + +pub fn fix_source(source: &str) -> String { + let outcome = analyze_source(Path::new(""), source); + apply_edits(source, &outcome.edits) +} + +fn analyze_source(path: &Path, source: &str) -> AnalysisOutcome { + let parse = rnix::Root::parse(source); + + if !parse.errors().is_empty() { + return AnalysisOutcome { + diagnostics: parse + .errors() + .iter() + .map(|error| { + let offset = parse_error_offset(error, source.len()); + let (line, column) = line_column(source, offset); + Diagnostic { + path: path.to_path_buf(), + rule: PARSE_ERROR_RULE, + message: error.to_string(), + line, + column, + } + }) + .collect(), + edits: Vec::new(), + }; + } + + let root = parse.tree(); + let mut diagnostics = Vec::new(); + let mut edits = Vec::new(); + + for inherit in root.syntax().descendants().filter_map(ast::Inherit::cast) { + if inherit.attrs().next().is_some() { + continue; + } + + let range = inherit.syntax().text_range(); + let start = usize::from(range.start()); + let end = usize::from(range.end()); + let (line, column) = line_column(source, start); + + diagnostics.push(Diagnostic { + path: path.to_path_buf(), + rule: EMPTY_INHERIT_RULE, + message: "remove empty inherit statement".to_owned(), + line, + column, + }); + + edits.push(Edit { + start: deletion_start(source, start, end), + end: deletion_end(source, start, end), + replacement: String::new(), + }); + } + + AnalysisOutcome { diagnostics, edits } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Edit { + start: usize, + end: usize, + replacement: String, +} + +fn apply_edits(source: &str, edits: &[Edit]) -> String { + let mut fixed = source.to_owned(); + let mut sorted = edits.to_vec(); + sorted.sort_by_key(|edit| edit.start); + + for edit in sorted.into_iter().rev() { + fixed.replace_range(edit.start..edit.end, &edit.replacement); + } + + fixed +} + +fn deletion_start(source: &str, start: usize, end: usize) -> usize { + let line_start = source[..start].rfind('\n').map_or(0, |index| index + 1); + + if source[line_start..start].trim().is_empty() && trailing_line_text(source, end).trim().is_empty() + { + line_start + } else { + start + } +} + +fn deletion_end(source: &str, start: usize, end: usize) -> usize { + let line_start = source[..start].rfind('\n').map_or(0, |index| index + 1); + + if source[line_start..start].trim().is_empty() && trailing_line_text(source, end).trim().is_empty() + { + source[end..] + .find('\n') + .map_or(end, |relative| end + relative + 1) + } else { + end + } +} + +fn trailing_line_text(source: &str, end: usize) -> &str { + let line_end = source[end..] + .find('\n') + .map_or(source.len(), |relative| end + relative); + + &source[end..line_end] +} + +fn collect_nix_files(path: &Path) -> io::Result> { + let metadata = fs::metadata(path)?; + + if metadata.is_file() { + return Ok(if is_nix_file(path) { + vec![path.to_path_buf()] + } else { + Vec::new() + }); + } + + if !metadata.is_dir() { + return Ok(Vec::new()); + } + + let mut files = Vec::new(); + collect_nix_files_from_dir(path, &mut files)?; + files.sort(); + Ok(files) +} + +fn collect_nix_files_from_dir(path: &Path, files: &mut Vec) -> io::Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + let metadata = entry.metadata()?; + + if metadata.is_dir() { + collect_nix_files_from_dir(&path, files)?; + } else if metadata.is_file() && is_nix_file(&path) { + files.push(path); + } + } + + Ok(()) +} + +fn is_nix_file(path: &Path) -> bool { + path.extension().is_some_and(|extension| extension == "nix") +} + +fn parse_error_offset(error: &rnix::ParseError, end_offset: usize) -> usize { + let range = match error { + rnix::ParseError::Unexpected(range) + | rnix::ParseError::UnexpectedExtra(range) + | rnix::ParseError::UnexpectedWanted(_, range, _) + | rnix::ParseError::UnexpectedDoubleBind(range) + | rnix::ParseError::DuplicatedArgs(range, _) => Some(range), + rnix::ParseError::UnexpectedEOF + | rnix::ParseError::UnexpectedEOFWanted(_) + | rnix::ParseError::RecursionLimitExceeded => None, + _ => None, + }; + + range.map_or(end_offset, |range| usize::from(range.start())) +} + +fn line_column(source: &str, offset: usize) -> (usize, usize) { + let prefix = &source[..offset.min(source.len())]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let line_start = prefix.rfind('\n').map_or(0, |index| index + 1); + let column = prefix[line_start..].chars().count() + 1; + + (line, column) } diff --git a/src/xatu/lib_test.rs b/src/xatu/lib_test.rs index d38e6d4..58f526d 100644 --- a/src/xatu/lib_test.rs +++ b/src/xatu/lib_test.rs @@ -1,4 +1,111 @@ +use std::path::Path; + +use xatu::{Diagnostic, RunMode, RunStatus}; + #[test] -fn greeting_returns_hello_message() { - assert_eq!(xatu::greeting(), "Hello from Xatu"); +fn empty_inherit_reports_diagnostic() { + let diagnostics = xatu::diagnostics_for_source(Path::new("input.nix"), "{ inherit; value = 1; }"); + + assert_eq!( + diagnostics, + vec![Diagnostic { + path: Path::new("input.nix").to_path_buf(), + rule: "empty_inherit", + message: "remove empty inherit statement".to_owned(), + line: 1, + column: 3, + }] + ); +} + +#[test] +fn non_empty_inherit_reports_clean() { + let diagnostics = xatu::diagnostics_for_source(Path::new("input.nix"), "{ inherit value; }"); + + assert!(diagnostics.is_empty()); +} + +#[test] +fn fix_removes_standalone_empty_inherit_line() { + let fixed = xatu::fix_source("{\n inherit;\n value = 1;\n}\n"); + + assert_eq!(fixed, "{\n value = 1;\n}\n"); +} + +#[test] +fn fix_removes_inline_empty_inherit_statement() { + let fixed = xatu::fix_source("{ inherit; value = 1; }\n"); + + assert_eq!(fixed, "{ value = 1; }\n"); +} + +#[test] +fn parse_errors_skip_empty_inherit_rule() { + let diagnostics = xatu::diagnostics_for_source(Path::new("input.nix"), "{ inherit;\n"); + + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].rule, "parse_error"); +} + +#[test] +fn run_returns_findings_when_empty_inherit_exists() { + let workspace = TestWorkspace::new(); + let file = workspace.write("input.nix", "{ inherit; }\n"); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let status = xatu::run(&[file], RunMode::Check, &mut stdout, &mut stderr).unwrap(); + + assert_eq!(status, RunStatus::Findings); + assert!(stderr.is_empty()); + assert!(String::from_utf8(stdout).unwrap().contains("empty_inherit")); +} + +#[test] +fn run_fix_updates_file() { + let workspace = TestWorkspace::new(); + let file = workspace.write("input.nix", "{\n inherit;\n value = 1;\n}\n"); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let status = xatu::run(&[file.clone()], RunMode::Fix, &mut stdout, &mut stderr).unwrap(); + + assert_eq!(status, RunStatus::Findings); + assert!(stderr.is_empty()); + assert_eq!( + std::fs::read_to_string(file).unwrap(), + "{\n value = 1;\n}\n" + ); +} + +struct TestWorkspace { + root: std::path::PathBuf, +} + +impl TestWorkspace { + fn new() -> Self { + static NEXT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = std::env::temp_dir().join(format!("xatu-test-{id}")); + + if root.exists() { + std::fs::remove_dir_all(&root).unwrap(); + } + + std::fs::create_dir(&root).unwrap(); + + Self { root } + } + + fn write(&self, name: &str, contents: &str) -> std::path::PathBuf { + let path = self.root.join(name); + std::fs::write(&path, contents).unwrap(); + path + } +} + +impl Drop for TestWorkspace { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.root).unwrap(); + } } diff --git a/src/xatu/main.rs b/src/xatu/main.rs index c532cbf..b8fd84e 100644 --- a/src/xatu/main.rs +++ b/src/xatu/main.rs @@ -1,3 +1,26 @@ -fn main() { - println!("{}", xatu::greeting()); +use std::path::PathBuf; +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut mode = xatu::RunMode::Check; + let mut paths = Vec::new(); + + for argument in std::env::args_os().skip(1) { + if argument == "--fix" { + mode = xatu::RunMode::Fix; + } else { + paths.push(PathBuf::from(argument)); + } + } + + let mut stdout = std::io::stdout().lock(); + let mut stderr = std::io::stderr().lock(); + + match xatu::run(&paths, mode, &mut stdout, &mut stderr) { + Ok(status) => ExitCode::from(status.code()), + Err(error) => { + eprintln!("error: {error}"); + ExitCode::from(2) + } + } }