From e2ccc0328649ef6985dce04c3e930f0b8f77eccd Mon Sep 17 00:00:00 2001 From: alexion Date: Thu, 17 Sep 2026 00:52:02 -0400 Subject: [PATCH] refactor(xatu): split lint rule framework --- src/xatu/BUILD | 11 +- src/xatu/analysis.rs | 52 +++++++ src/xatu/diagnostic.rs | 91 ++++++++++++ src/xatu/edit.rs | 80 ++++++++++ src/xatu/empty_inherit.rs | 37 +++++ src/xatu/file_set.rs | 45 ++++++ src/xatu/lib.rs | 300 ++------------------------------------ src/xatu/rules.rs | 43 ++++++ src/xatu/runner.rs | 81 ++++++++++ 9 files changed, 452 insertions(+), 288 deletions(-) create mode 100644 src/xatu/analysis.rs create mode 100644 src/xatu/diagnostic.rs create mode 100644 src/xatu/edit.rs create mode 100644 src/xatu/empty_inherit.rs create mode 100644 src/xatu/file_set.rs create mode 100644 src/xatu/rules.rs create mode 100644 src/xatu/runner.rs diff --git a/src/xatu/BUILD b/src/xatu/BUILD index 0be7aee..d7b6b4a 100644 --- a/src/xatu/BUILD +++ b/src/xatu/BUILD @@ -3,7 +3,16 @@ load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") rust_library( name = "xatu_lib", crate_name = "xatu", - srcs = ["lib.rs"], + srcs = [ + "analysis.rs", + "diagnostic.rs", + "edit.rs", + "empty_inherit.rs", + "file_set.rs", + "lib.rs", + "rules.rs", + "runner.rs", + ], deps = [ "//third_party/crates:rnix", "//third_party/crates:rowan", diff --git a/src/xatu/analysis.rs b/src/xatu/analysis.rs new file mode 100644 index 0000000..1f6bc73 --- /dev/null +++ b/src/xatu/analysis.rs @@ -0,0 +1,52 @@ +use std::path::Path; + +use crate::diagnostic::{parse_error_diagnostics, Diagnostic}; +use crate::edit::Edit; +use crate::rules::{self, RuleContext}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AnalysisOutcome { + pub(crate) diagnostics: Vec, + pub(crate) edits: Vec, +} + +pub(crate) fn analyze_source(path: &Path, source: &str) -> AnalysisOutcome { + let parse = rnix::Root::parse(source); + + if !parse.errors().is_empty() { + return AnalysisOutcome { + diagnostics: parse_error_diagnostics(path, source, parse.errors()), + edits: Vec::new(), + }; + } + + let root = parse.tree(); + let context = RuleContext { + source, + root: &root, + }; + let mut findings = Vec::new(); + + for rule in rules::all() { + rule.check(&context, &mut findings); + } + + let mut diagnostics = Vec::with_capacity(findings.len()); + let mut edits = Vec::new(); + + for finding in findings { + diagnostics.push(Diagnostic::at_offset( + path, + source, + finding.rule, + finding.message, + finding.offset, + )); + + if let Some(fix) = finding.fix { + edits.push(fix); + } + } + + AnalysisOutcome { diagnostics, edits } +} diff --git a/src/xatu/diagnostic.rs b/src/xatu/diagnostic.rs new file mode 100644 index 0000000..1bf4c7c --- /dev/null +++ b/src/xatu/diagnostic.rs @@ -0,0 +1,91 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +const PARSE_ERROR_RULE: &str = "parse_error"; + +#[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 Diagnostic { + pub(crate) fn at_offset( + path: &Path, + source: &str, + rule: &'static str, + message: String, + offset: usize, + ) -> Self { + let (line, column) = line_column(source, offset); + + Self { + path: path.to_path_buf(), + rule, + message, + 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 + ) + } +} + +pub(crate) fn parse_error_diagnostics( + path: &Path, + source: &str, + errors: &[rnix::ParseError], +) -> Vec { + errors + .iter() + .map(|error| { + Diagnostic::at_offset( + path, + source, + PARSE_ERROR_RULE, + error.to_string(), + parse_error_offset(error, source.len()), + ) + }) + .collect() +} + +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/edit.rs b/src/xatu/edit.rs new file mode 100644 index 0000000..a875a58 --- /dev/null +++ b/src/xatu/edit.rs @@ -0,0 +1,80 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Edit { + start: usize, + end: usize, + replacement: String, +} + +impl Edit { + pub(crate) fn delete_statement(source: &str, start: usize, end: usize) -> Self { + Self { + start: deletion_start(source, start, end), + end: deletion_end(source, start, end), + replacement: String::new(), + } + } +} + +pub(crate) fn apply_edits(source: &str, edits: &[Edit]) -> String { + let mut fixed = source.to_owned(); + let mut edits = non_overlapping_edits(edits); + + for edit in edits.drain(..).rev() { + fixed.replace_range(edit.start..edit.end, &edit.replacement); + } + + fixed +} + +fn non_overlapping_edits(edits: &[Edit]) -> Vec { + let mut sorted = edits.to_vec(); + sorted.sort_by_key(|edit| (edit.start, edit.end)); + + let mut filtered = Vec::new(); + let mut previous_end = 0; + + for edit in sorted { + if edit.start < previous_end { + continue; + } + + previous_end = edit.end; + filtered.push(edit); + } + + filtered +} + +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] +} diff --git a/src/xatu/empty_inherit.rs b/src/xatu/empty_inherit.rs new file mode 100644 index 0000000..f260b43 --- /dev/null +++ b/src/xatu/empty_inherit.rs @@ -0,0 +1,37 @@ +use rnix::ast; +use rowan::ast::AstNode; + +use crate::edit::Edit; +use crate::rules::{Finding, Rule, RuleContext}; + +pub(crate) static RULE: EmptyInherit = EmptyInherit; + +pub(crate) struct EmptyInherit; + +impl Rule for EmptyInherit { + fn name(&self) -> &'static str { + "empty_inherit" + } + + fn check(&self, context: &RuleContext<'_>, findings: &mut Vec) { + for inherit in context + .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 fix = Edit::delete_statement(context.source, start, end); + + findings.push( + Finding::new(self.name(), "remove empty inherit statement", start).with_fix(fix), + ); + } + } +} diff --git a/src/xatu/file_set.rs b/src/xatu/file_set.rs new file mode 100644 index 0000000..98053ff --- /dev/null +++ b/src/xatu/file_set.rs @@ -0,0 +1,45 @@ +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +pub(crate) 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() == Some(OsStr::new("nix")) +} diff --git a/src/xatu/lib.rs b/src/xatu/lib.rs index be00d6d..f6260cd 100644 --- a/src/xatu/lib.rs +++ b/src/xatu/lib.rs @@ -1,295 +1,21 @@ -use std::fmt; -use std::fs; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; +use std::path::Path; -use rnix::ast; -use rowan::ast::AstNode; +mod analysis; +mod diagnostic; +mod edit; +mod empty_inherit; +mod file_set; +mod rules; +mod runner; -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 use diagnostic::Diagnostic; +pub use runner::{run, RunMode, RunStatus}; pub fn diagnostics_for_source(path: &Path, source: &str) -> Vec { - analyze_source(path, source).diagnostics + analysis::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) + let outcome = analysis::analyze_source(Path::new(""), source); + edit::apply_edits(source, &outcome.edits) } diff --git a/src/xatu/rules.rs b/src/xatu/rules.rs new file mode 100644 index 0000000..542f429 --- /dev/null +++ b/src/xatu/rules.rs @@ -0,0 +1,43 @@ +use rnix::Root; + +use crate::edit::Edit; + +pub(crate) struct RuleContext<'a> { + pub(crate) source: &'a str, + pub(crate) root: &'a Root, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Finding { + pub(crate) rule: &'static str, + pub(crate) message: String, + pub(crate) offset: usize, + pub(crate) fix: Option, +} + +impl Finding { + pub(crate) fn new(rule: &'static str, message: impl Into, offset: usize) -> Self { + Self { + rule, + message: message.into(), + offset, + fix: None, + } + } + + pub(crate) fn with_fix(mut self, fix: Edit) -> Self { + self.fix = Some(fix); + self + } +} + +pub(crate) trait Rule: Sync { + fn name(&self) -> &'static str; + fn check(&self, context: &RuleContext<'_>, findings: &mut Vec); +} + +static RULES: [&'static dyn Rule; 1] = [&crate::empty_inherit::RULE]; + +pub(crate) fn all() -> &'static [&'static dyn Rule] { + &RULES +} diff --git a/src/xatu/runner.rs b/src/xatu/runner.rs new file mode 100644 index 0000000..600a31f --- /dev/null +++ b/src/xatu/runner.rs @@ -0,0 +1,81 @@ +use std::fs; +use std::io::{self, Write}; +use std::path::PathBuf; + +use crate::analysis::analyze_source; +use crate::edit::apply_edits; +use crate::file_set::collect_nix_files; + +#[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, + } + } +} + +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) +}