nff: separate format and lint commands
This commit is contained in:
parent
9791296634
commit
6362ade5bd
3 changed files with 376 additions and 135 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
503
src/main.rs
503
src/main.rs
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue