From 6362ade5bd23db76a9926a96d7a9a0ee8e2930d6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 2 Jun 2025 07:56:12 +0300 Subject: [PATCH] nff: separate format and lint commands --- Cargo.lock | 7 + Cargo.toml | 1 + src/main.rs | 503 ++++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 376 insertions(+), 135 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 898d315..1b11d7f 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 23e80a3..4bb2743 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/main.rs b/src/main.rs index 7cb3a77..91639f7 100755 --- a/src/main.rs +++ b/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, - - /// 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>, } -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, + + /// 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, + + /// 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>, + }, +} + +fn discover_nftables_files() -> Result> { + 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, + 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, + json: bool, + style_warnings: bool, + best_practices: bool, + performance_hints: bool, + security_warnings: bool, + modules: Option>, + 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>, + 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 {