nff: separate format and lint commands

This commit is contained in:
raf 2025-06-02 07:56:12 +03:00
commit 6362ade5bd
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 376 additions and 135 deletions

7
Cargo.lock generated
View file

@ -176,6 +176,12 @@ dependencies = [
"byteorder",
]
[[package]]
name = "glob"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]]
name = "hashbrown"
version = "0.15.3"
@ -273,6 +279,7 @@ dependencies = [
"anyhow",
"clap",
"cstree",
"glob",
"logos",
"regex",
"serde",

View file

@ -15,3 +15,4 @@ text-size = "1.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
regex = "1.11.1"
glob = "0.3"

View file

@ -6,7 +6,8 @@ mod parser;
mod syntax;
use anyhow::{Context, Result};
use clap::Parser;
use clap::{Parser, Subcommand};
use glob::glob;
use std::fs;
use std::io::{self, Write};
use std::path::Path;
@ -34,71 +35,176 @@ enum FormatterError {
#[command(
name = "nff",
version = "0.1.0",
about = "A high-quality nftables formatter and beautifier",
long_about = "nff (nftables formatter) is a tool for formatting and beautifying nftables configuration files with proper indentation and structure."
about = "A high-quality nftables formatter and linter",
long_about = "nff (nftables formatter) is a tool for formatting and linting nftables configuration files with proper indentation and structure."
)]
struct Args {
/// nftables config file (e.g: /etc/nftables.conf)
#[arg(short, long, value_name = "FILE")]
file: String,
/// Type of indentation
#[arg(short, long, default_value = "tabs", value_parser = clap::value_parser!(IndentStyle))]
indent: IndentStyle,
/// Output file (writes to stdout if not specified)
#[arg(short, long, value_name = "FILE")]
output: Option<String>,
/// Optimize output by removing excessive empty lines
#[arg(long)]
optimize: bool,
/// Number of spaces per indentation level (only used with --indent=spaces)
#[arg(long, default_value = "2", value_name = "N")]
spaces: usize,
#[command(subcommand)]
command: Commands,
/// Show debug information (tokens, AST, etc.)
#[arg(long)]
#[arg(long, global = true)]
debug: bool,
/// Check syntax only, don't format
#[arg(long)]
check: bool,
/// Run diagnostics and show issues (syntax, style, best practices)
#[arg(long)]
diagnostics: bool,
/// Output diagnostics in JSON format (useful for tooling integration)
#[arg(long)]
json: bool,
/// Include style warnings in diagnostics
#[arg(long, default_value = "true")]
style_warnings: bool,
/// Include best practice recommendations in diagnostics
#[arg(long, default_value = "true")]
best_practices: bool,
/// Include performance hints in diagnostics
#[arg(long, default_value = "true")]
performance_hints: bool,
/// Include security warnings in diagnostics
#[arg(long, default_value = "true")]
security_warnings: bool,
/// Diagnostic modules to run (comma-separated: lexical,syntax,style,semantic)
#[arg(long, value_delimiter = ',')]
modules: Option<Vec<String>>,
}
fn process_nftables_config(args: Args) -> Result<()> {
let path = Path::new(&args.file);
#[derive(Subcommand, Debug, Clone)]
enum Commands {
/// Format nftables configuration files
Format {
/// nftables config file (e.g: /etc/nftables.conf). If not provided, formats all .nft files in current directory
#[arg(value_name = "FILE")]
file: Option<String>,
/// Type of indentation
#[arg(short, long, default_value = "tabs", value_parser = clap::value_parser!(IndentStyle))]
indent: IndentStyle,
/// Print formatted output to stdout instead of modifying files in place
#[arg(long)]
stdout: bool,
/// Optimize output by removing excessive empty lines
#[arg(long)]
optimize: bool,
/// Number of spaces per indentation level (only used with --indent=spaces)
#[arg(long, default_value = "2", value_name = "N")]
spaces: usize,
/// Check syntax only, don't format
#[arg(long)]
check: bool,
},
/// Lint nftables configuration files and show diagnostics
Lint {
/// nftables config file (e.g: /etc/nftables.conf). If not provided, lints all .nft files in current directory
#[arg(value_name = "FILE")]
file: Option<String>,
/// Output diagnostics in JSON format (useful for tooling integration)
#[arg(long)]
json: bool,
/// Include style warnings in diagnostics
#[arg(long, default_value = "true")]
style_warnings: bool,
/// Include best practice recommendations in diagnostics
#[arg(long, default_value = "true")]
best_practices: bool,
/// Include performance hints in diagnostics
#[arg(long, default_value = "true")]
performance_hints: bool,
/// Include security warnings in diagnostics
#[arg(long, default_value = "true")]
security_warnings: bool,
/// Diagnostic modules to run (comma-separated: lexical,syntax,style,semantic)
#[arg(long, value_delimiter = ',')]
modules: Option<Vec<String>>,
},
}
fn discover_nftables_files() -> Result<Vec<String>> {
let mut files = Vec::new();
// Common nftables file patterns
let patterns = [
"*.nft",
"*.nftables",
"/etc/nftables.conf",
"/etc/nftables/*.nft",
];
for pattern in &patterns {
match glob(pattern) {
Ok(paths) => {
for entry in paths {
match entry {
Ok(path) => {
if path.is_file() {
if let Some(path_str) = path.to_str() {
files.push(path_str.to_string());
}
}
}
Err(e) => eprintln!("Warning: Error reading path: {}", e),
}
}
}
Err(e) => {
// Only warn for non-current directory patterns
if !pattern.starts_with("*.") {
eprintln!("Warning: Failed to search pattern {}: {}", pattern, e);
}
}
}
}
if files.is_empty() {
return Err(anyhow::anyhow!(
"No nftables files found. Please specify a file explicitly or ensure .nft/.nftables files exist in the current directory."
));
}
// Remove duplicates and sort
files.sort();
files.dedup();
Ok(files)
}
fn process_format_command(
file: Option<String>,
indent: IndentStyle,
stdout: bool,
optimize: bool,
spaces: usize,
check: bool,
debug: bool,
) -> Result<()> {
let files = match file {
Some(f) => vec![f],
None => discover_nftables_files()?,
};
let is_multiple_files = files.len() > 1;
for file_path in files {
if let Err(e) = process_single_file_format(
&file_path,
indent,
stdout,
optimize,
spaces,
check,
debug,
is_multiple_files,
) {
eprintln!("Error processing {}: {}", file_path, e);
if !is_multiple_files {
return Err(e);
}
}
}
Ok(())
}
fn process_single_file_format(
file: &str,
indent: IndentStyle,
stdout: bool,
optimize: bool,
spaces: usize,
check: bool,
debug: bool,
is_multiple_files: bool,
) -> Result<()> {
let path = Path::new(&file);
if !path.exists() {
return Err(FormatterError::FileNotFound(args.file).into());
return Err(FormatterError::FileNotFound(file.to_string()).into());
}
if !path.is_file() {
@ -106,12 +212,12 @@ fn process_nftables_config(args: Args) -> Result<()> {
}
// Read file contents
let source = fs::read_to_string(&args.file)
.with_context(|| format!("Failed to read file: {}", args.file))?;
let source =
fs::read_to_string(&file).with_context(|| format!("Failed to read file: {}", file))?;
// Tokenize
let mut lexer = NftablesLexer::new(&source);
let tokens = if args.debug {
let tokens = if debug {
// Use error-recovery tokenization for debug mode
lexer.tokenize_with_errors()
} else {
@ -120,7 +226,7 @@ fn process_nftables_config(args: Args) -> Result<()> {
.map_err(|e| FormatterError::ParseError(e.to_string()))?
};
if args.debug {
if debug {
eprintln!("=== TOKENS ===");
for (i, token) in tokens.iter().enumerate() {
eprintln!(
@ -146,60 +252,8 @@ fn process_nftables_config(args: Args) -> Result<()> {
eprintln!();
}
// Run diagnostics if requested (do this early to catch parse errors)
if args.diagnostics || args.json {
let diagnostic_config = DiagnosticConfig {
enable_style_warnings: args.style_warnings,
enable_best_practices: args.best_practices,
enable_performance_hints: args.performance_hints,
enable_security_warnings: args.security_warnings,
max_line_length: 120,
max_empty_lines: if args.optimize { 1 } else { 2 },
preferred_indent: Some(match args.indent {
IndentStyle::Tabs => "tabs".to_string(),
IndentStyle::Spaces => "spaces".to_string(),
}),
};
let analyzer = DiagnosticAnalyzer::new(diagnostic_config);
let diagnostics = if let Some(modules) = &args.modules {
let module_names: Vec<&str> = modules.iter().map(|s| s.as_str()).collect();
analyzer.analyze_with_modules(&source, &args.file, &module_names)
} else {
analyzer.analyze(&source, &args.file)
};
if args.json {
// Output JSON format for tooling integration
match diagnostics.to_json() {
Ok(json) => println!("{}", json),
Err(e) => {
if args.json {
// Even JSON serialization errors should be in JSON format when --json is used
let error_json =
format!(r#"{{"error": "JSON serialization failed: {}"}}"#, e);
println!("{}", error_json);
} else {
eprintln!("Error serializing diagnostics to JSON: {}", e);
}
}
}
} else {
// Output human-readable format
println!("{}", diagnostics.to_human_readable());
}
// Exit with non-zero code if there are errors
if diagnostics.has_errors() {
std::process::exit(1);
}
return Ok(());
}
// Parse (only if not doing diagnostics)
let ruleset = if args.debug {
// Parse
let ruleset = if debug {
// Use error-recovery parsing for debug mode
let (parsed_ruleset, errors) = NftablesParser::parse_with_errors(&source);
if !errors.is_empty() {
@ -217,51 +271,230 @@ fn process_nftables_config(args: Args) -> Result<()> {
.map_err(|e| FormatterError::ParseError(e.to_string()))?
};
if args.debug {
if debug {
eprintln!("=== AST ===");
eprintln!("{:#?}", ruleset);
eprintln!();
}
if args.check {
println!("Syntax check passed for: {}", args.file);
if check {
println!("Syntax check passed for: {}", file);
return Ok(());
}
// Format
let config = FormatConfig {
indent_style: args.indent,
spaces_per_level: args.spaces,
optimize: args.optimize,
max_empty_lines: if args.optimize { 1 } else { 2 },
indent_style: indent,
spaces_per_level: spaces,
optimize,
max_empty_lines: if optimize { 1 } else { 2 },
};
let formatter = NftablesFormatter::new(config);
let formatted_output = formatter.format_ruleset(&ruleset);
// Write output
match &args.output {
Some(output_file) => {
fs::write(output_file, &formatted_output)
.with_context(|| format!("Failed to write to output file: {}", output_file))?;
println!("Formatted output written to: {}", output_file);
if stdout {
// Output to stdout
if is_multiple_files {
println!("=== {} ===", file);
}
None => {
io::stdout()
.write_all(formatted_output.as_bytes())
.with_context(|| "Failed to write to stdout")?;
io::stdout()
.write_all(formatted_output.as_bytes())
.with_context(|| "Failed to write to stdout")?;
} else {
// Format in place
fs::write(file, &formatted_output)
.with_context(|| format!("Failed to write formatted content back to: {}", file))?;
if is_multiple_files || debug {
println!("Formatted: {}", file);
}
}
Ok(())
}
fn process_lint_command(
file: Option<String>,
json: bool,
style_warnings: bool,
best_practices: bool,
performance_hints: bool,
security_warnings: bool,
modules: Option<Vec<String>>,
debug: bool,
) -> Result<()> {
let files = match file {
Some(f) => vec![f],
None => discover_nftables_files()?,
};
let is_multiple_files = files.len() > 1;
for file_path in files {
if let Err(e) = process_single_file_lint(
&file_path,
json,
style_warnings,
best_practices,
performance_hints,
security_warnings,
modules.as_ref(),
debug,
is_multiple_files,
) {
eprintln!("Error processing {}: {}", file_path, e);
if !is_multiple_files {
return Err(e);
}
}
}
Ok(())
}
fn process_single_file_lint(
file: &str,
json: bool,
style_warnings: bool,
best_practices: bool,
performance_hints: bool,
security_warnings: bool,
modules: Option<&Vec<String>>,
debug: bool,
is_multiple_files: bool,
) -> Result<()> {
let path = Path::new(&file);
if !path.exists() {
return Err(FormatterError::FileNotFound(file.to_string()).into());
}
if !path.is_file() {
return Err(FormatterError::InvalidFile("Not a regular file".to_string()).into());
}
// Read file contents
let source =
fs::read_to_string(&file).with_context(|| format!("Failed to read file: {}", file))?;
if debug {
// Tokenize for debug output
let mut lexer = NftablesLexer::new(&source);
let tokens = lexer.tokenize_with_errors();
eprintln!("=== TOKENS ===");
for (i, token) in tokens.iter().enumerate() {
eprintln!(
"{:3}: {:?} @ {:?} = '{}'",
i, token.kind, token.range, token.text
);
}
eprintln!();
// Build and validate CST
eprintln!("=== CST ===");
let cst_tree = CstBuilder::build_tree(&tokens);
match CstBuilder::validate_tree(&cst_tree) {
Ok(()) => eprintln!("CST validation passed"),
Err(e) => eprintln!("CST validation error: {}", e),
}
eprintln!();
}
// Run diagnostics
let diagnostic_config = DiagnosticConfig {
enable_style_warnings: style_warnings,
enable_best_practices: best_practices,
enable_performance_hints: performance_hints,
enable_security_warnings: security_warnings,
max_line_length: 120,
max_empty_lines: 2,
preferred_indent: None, // Don't enforce indent style in lint mode
};
let analyzer = DiagnosticAnalyzer::new(diagnostic_config);
let diagnostics = if let Some(modules) = &modules {
let module_names: Vec<&str> = modules.iter().map(|s| s.as_str()).collect();
analyzer.analyze_with_modules(&source, &file, &module_names)
} else {
analyzer.analyze(&source, &file)
};
if json {
// Output JSON format for tooling integration
match diagnostics.to_json() {
Ok(json) => println!("{}", json),
Err(e) => {
// Even JSON serialization errors should be in JSON format when --json is used
let error_json = format!(r#"{{"error": "JSON serialization failed: {}"}}"#, e);
println!("{}", error_json);
}
}
} else {
// Output human-readable format
if is_multiple_files {
println!("=== {} ===", file);
}
println!("{}", diagnostics.to_human_readable());
}
// Exit with non-zero code if there are errors
if diagnostics.has_errors() {
std::process::exit(1);
}
Ok(())
}
fn main() -> Result<()> {
let args = Args::parse();
if let Err(e) = process_nftables_config(args.clone()) {
if args.json {
// Output error in JSON format when --json flag is used
let result = match &args.command {
Commands::Format {
file,
indent,
stdout,
optimize,
spaces,
check,
} => process_format_command(
file.clone(),
*indent,
*stdout,
*optimize,
*spaces,
*check,
args.debug,
),
Commands::Lint {
file,
json,
style_warnings,
best_practices,
performance_hints,
security_warnings,
modules,
} => process_lint_command(
file.clone(),
*json,
*style_warnings,
*best_practices,
*performance_hints,
*security_warnings,
modules.clone(),
args.debug,
),
};
if let Err(e) = result {
// Check if we're in lint mode with JSON output for error formatting
let use_json = match &args.command {
Commands::Lint { json, .. } => *json,
_ => false,
};
if use_json {
// Output error in JSON format when --json flag is used in lint mode
let error_json = format!(r#"{{"error": "{}"}}"#, e);
println!("{}", error_json);
} else {