WIP: allow using nff as a diagnostics source #1

Draft
NotAShelf wants to merge 26 commits from diagnostics into main
12 changed files with 3500 additions and 293 deletions

27
.config/nextest.toml Normal file
View file

@ -0,0 +1,27 @@
[test-groups]
nff = { max-threads = 1 }
[profile.default]
# "retries" defines the number of times a test should be retried. If set to a
# non-zero value, tests that succeed on a subsequent attempt will be marked as
# flaky. Can be overridden through the `--retries` option.
retries = 2
# This will display all of fail, retry, slow
# see https://nexte.st/book/other-options.html?highlight=failure-output#--status-level-and---final-status-level
status-level = "skip"
# Treat a test that takes longer than this period as slow, and print a message.
# Given a non-zero positive integer, shutdown the tests when the number periods
# have passed.
slow-timeout = { period = "30s", terminate-after = 4 }
# * "immediate-final": output failures as soon as they happen and at the end of
# the test run
failure-output = "immediate-final"
# Do not cancel the test run on the first failure.
fail-fast = false
test-threads = 2

149
Cargo.lock generated
View file

@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.13"
@ -167,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"
@ -189,6 +204,12 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -245,6 +266,12 @@ dependencies = [
"logos-codegen",
]
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "nff"
version = "0.1.0"
@ -252,11 +279,37 @@ dependencies = [
"anyhow",
"clap",
"cstree",
"glob",
"logos",
"num_enum",
"regex",
"serde",
"serde_json",
"text-size",
"thiserror",
]
[[package]]
name = "num_enum"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
dependencies = [
"num_enum_derive",
]
[[package]]
name = "num_enum_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "parking_lot"
version = "0.12.3"
@ -280,6 +333,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "proc-macro-crate"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
@ -307,6 +369,29 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
@ -322,6 +407,12 @@ dependencies = [
"semver",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -334,6 +425,38 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "smallvec"
version = "1.15.0"
@ -395,6 +518,23 @@ dependencies = [
"syn",
]
[[package]]
name = "toml_datetime"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
[[package]]
name = "toml_edit"
version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap",
"toml_datetime",
"winnow",
]
[[package]]
name = "triomphe"
version = "0.1.14"
@ -481,3 +621,12 @@ name = "windows_x86_64_msvc"
version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
[[package]]
name = "winnow"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
dependencies = [
"memchr",
]

View file

@ -12,3 +12,8 @@ thiserror = "2.0"
logos = "0.15"
cstree = "0.12"
text-size = "1.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
regex = "1.11"
glob = "0.3"
num_enum = "0.7"

451
README.md
View file

@ -1,27 +1,33 @@
# nff
This is a high performance configuration parser and written in Rust. The goal is
to receive possibly jumbled up nftables rule files, and output ✨ pretty ✨
human readable output in return. The main emphasis is on the syntax-aware
formatting with comprehensive grammar support. It is a future goal to allow
editors to hook into nff in order to format your rulesets directly from your
editor, or as a diagnostics source.
This is a high performance, low overhead configuration parser for nftables,
written in Rust. Syntax-aware parsing allows nff to provide a complete formatter
_and_ a linter for nftables. With the formatter the goal is to receive possibly
jumbled up nftables rule files, and output ✨ pretty ✨ human readable output in
return. The linter on another hand, will demonstrate possible syntax,
performance or stylistic errors.
The main emphasis, however, is on the syntax-aware formatting with comprehensive
grammar support.
## Features
nff is in its early stages of development. While _most_ of the syntax is
supported, I cannot guarantee that _everything_ is supported just yet.
> [!NOTE]
> nff is in its early stages of development. While _most_ of the syntax is
> supported, I cannot guarantee that _everything_ is supported just yet.
### Core Functionality
Basic functionality of nff that most users will be interested in
- **Syntax-aware formatting** - Deep understanding of nftables grammar with
semantic preservation
- **Multi-family support** - Handles `inet`, `ip`, `ip6`, `arp`, `bridge`, and
`netdev` table families
- **Multi-family support** - Handles `inet`, `ip`, `ip6`, `arp`, `bridge`, and
`netdev` table families
- **CIDR notation** - Proper handling of network addresses (`192.168.1.0/24`)
- **Chain properties** - Hooks, priorities (including negative), policies,
device bindings
- **Flexible indentation** - Configurable tabs/spaces with custom depth
- **CIDR notation** - Proper handling of network addresses (`192.168.1.0/24`)
- **Chain properties** - Hooks, priorities (including negative), policies,
device bindings
### Advanced Features
@ -30,26 +36,87 @@ supported, I cannot guarantee that _everything_ is supported just yet.
- **Validation** - Syntax checking with precise error locations
- **Optimization** - Configurable empty line reduction and whitespace control
### Diagnostics & Analysis
- **Comprehensive diagnostics** - Syntax, semantic, style, and best practice
analysis
- **Modular analysis** - Run specific diagnostic modules (`lexical`, `syntax`,
`style`, `semantic`)
- **LSP-compatible output** - JSON format for editor integration
- **Human-readable reports** - Detailed error messages with context and location
information
- **Configurable severity** - Control which diagnostic categories to
enable/disable
## Usage
### Formatting
```bash
# Basic formatting
nff -f /etc/nftables.conf
# Format a specific file (in place)
nff format /etc/nftables.conf
# Format all .nft files in current directory (in place)
nff format
# Custom indentation (4 spaces)
nff -f config.nft --indent spaces --spaces 4
nff format config.nft --indent spaces --spaces 4
# Optimize formatting (reduce empty lines)
nff -f config.nft --optimize
nff format config.nft --optimize
# Output to file
nff -f config.nft -o formatted.nft
# Output to stdout instead of modifying files
nff format config.nft --stdout
# Syntax validation only
nff -f config.nft --check
nff format config.nft --check
# Debug output for development (or debugging)
nff -f config.nft --debug
nff format config.nft --debug
```
### Linting and Diagnostics
```bash
# Run comprehensive diagnostics on a file
nff lint /etc/nftables.conf
# Lint all .nft files in current directory
nff lint
# JSON output for editor integration
nff lint config.nft --json
# Run specific diagnostic modules
nff lint config.nft --modules syntax,style
# Available modules: lexical, syntax, style, semantic
nff lint config.nft --modules semantic
# Configure diagnostic settings (note: flags are enabled by default)
nff lint config.nft --style-warnings=false --best-practices=false
# Debug output with diagnostics
nff lint config.nft --debug
```
### Parsing and CST Inspection
```bash
# Parse and display CST structure for debugging
nff parse /etc/nftables.conf
# Show tree structure with indentation
nff parse config.nft --tree
# Show detailed node information
nff parse config.nft --verbose
# Combined tree and verbose output
nff parse config.nft --tree --verbose
# Debug output with tokens and CST validation
nff parse config.nft --debug
```
## Architecture
@ -69,11 +136,248 @@ graph TD
AST --> Formatter
Formatter --> Output
CST --> Formatter
Input --> Diagnostics[Diagnostic System]
Diagnostics --> LexAnalyzer[Lexical Analyzer]
Diagnostics --> SyntaxAnalyzer[Syntax Analyzer]
Diagnostics --> StyleAnalyzer[Style Analyzer]
Diagnostics --> SemanticAnalyzer[Semantic Analyzer]
LexAnalyzer --> DiagOutput[JSON/Human Output]
SyntaxAnalyzer --> DiagOutput
StyleAnalyzer --> DiagOutput
SemanticAnalyzer --> DiagOutput
```
## Installation
Recommended way of installing nff is to use Nix.
Recommended way of installing nff is to use Nix. Add `nff` to your flake inputs,
and add the package to your `environment.systemPackages`. Alternatively, on
non-NixOS systems, it is possible to use `nix profile install` to install nff.
### Editor Integration
> [!TIP]
> Your editor not here? Open an issue. I can only add support for editors I use
> but pull requests documenting alternative editor setups are appreciated!
#### Neovim Setup
nff can be integrated into Neovim as a diagnostics source for nftables files.
Here are several setup approaches:
##### Option 1: Using none-ls
none-ls is the most common method of adding diagnostics sources in Neovim. While
I recommend using nvim-lint for its simplicity, below instructions document how
to set up null-ls.
```lua
local null_ls = require("null-ls")
null_ls.setup({
sources = {
-- nftables diagnostics
null_ls.builtins.diagnostics.nff.with({
command = "nff",
args = { "lint", "$FILENAME", "--json" },
format = "json",
check_exit_code = false,
filetypes = { "nftables" },
}),
-- nftables formatting
null_ls.builtins.formatting.nff.with({
command = "nff",
args = { "format", "$FILENAME", "--stdout" },
filetypes = { "nftables" },
}),
},
})
```
##### Option 2: Using nvim-lint (recommended)
Recommended way of adding nff as a diagnostics source in Neovim. Simple, low
overhead and not as error-prone as null-ls.
```lua
-- ~/.config/nvim/lua/config/lint.lua
require('lint').linters.nff = {
cmd = 'nff',
stdin = false,
args = { 'lint', '%s', '--json' },
stream = 'stdout',
ignore_exitcode = true,
parser = function(output)
local diagnostics = {}
local ok, decoded = pcall(vim.fn.json_decode, output)
if not ok or not decoded.diagnostics then
return diagnostics
end
for _, diagnostic in ipairs(decoded.diagnostics) do
table.insert(diagnostics, {
lnum = diagnostic.range.start.line,
col = diagnostic.range.start.character,
severity = diagnostic.severity == "Error" and vim.diagnostic.severity.ERROR or vim.diagnostic.severity.WARN,
message = diagnostic.message,
source = "nff",
code = diagnostic.code,
})
end
return diagnostics
end,
}
-- Setup linting for nftables files
vim.api.nvim_create_autocmd({ "BufEnter", "BufWritePost" }, {
pattern = "*.nft",
callback = function()
require("lint").try_lint("nff")
end,
})
```
##### Option 3: Custom Lua Function
Alternatively, if you don't want to use plugins, consider a setup such as this
one to do it without any reliance on plugins:
```lua
-- ~/.config/nvim/lua/nff.lua
local M = {}
function M.lint_nftables()
local filename = vim.fn.expand('%:p')
if vim.bo.filetype ~= 'nftables' then
return
end
local cmd = { 'nff', 'lint', filename, '--json' }
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if data then
local output = table.concat(data, '\n')
local ok, result = pcall(vim.fn.json_decode, output)
if ok and result.diagnostics then
local diagnostics = {}
for _, diag in ipairs(result.diagnostics) do
table.insert(diagnostics, {
lnum = diag.range.start.line,
col = diag.range.start.character,
severity = diag.severity == "Error" and vim.diagnostic.severity.ERROR or vim.diagnostic.severity.WARN,
message = diag.message,
source = "nff",
})
end
vim.diagnostic.set(vim.api.nvim_create_namespace('nff'), 0, diagnostics)
end
end
end,
})
end
-- Auto-run on save
vim.api.nvim_create_autocmd("BufWritePost", {
pattern = "*.nft",
callback = M.lint_nftables,
})
return M
```
## Diagnostic Categories
nff provides comprehensive analysis across multiple categories:
### Syntax Errors
- Parse errors with precise location information
- Missing tokens (semicolons, braces, etc.)
- Unexpected tokens
- Unterminated strings
- Invalid numbers
### Semantic Validation
- Unknown table families (`inet`, `ip`, `ip6`, etc.)
- Invalid chain types and hooks
- Incorrect priority values
- Missing chain policies
- Duplicate table/chain names
- Invalid CIDR notation
- Invalid port ranges
### Style Warnings
- Missing shebang line
- Inconsistent indentation (mixed tabs/spaces)
- Trailing whitespace
- Lines exceeding maximum length (configurable)
- Excessive empty lines
- Preferred syntax alternatives
### Best Practices
- Chains without explicit policies
- Rules without actions
- Overly permissive rules
- Duplicate or conflicting rules
- Unused variables or sets
- Deprecated syntax usage
- Missing documentation
- Security risks
### Performance Hints
- Inefficient rule ordering
- Large sets without timeouts
- Missing counters where beneficial
## JSON Output Format
When using `--json`, nff outputs LSP-compatible diagnostics:
```json
{
"diagnostics": [
{
"range": {
"start": { "line": 5, "character": 10 },
"end": { "line": 5, "character": 20 }
},
"severity": "Error",
"code": "NFT001",
"source": "nff",
"message": "Expected ';' after policy",
"related_information": [],
"code_actions": [],
"tags": []
}
],
"file_path": "config.nft",
"source_text": "..."
}
```
### Diagnostic Codes
nff uses structured diagnostic codes for categorization:
- **NFT001-NFT099**: Syntax errors
- **NFT101-NFT199**: Semantic errors
- **NFT201-NFT299**: Style warnings
- **NFT301-NFT399**: Best practice recommendations
- **NFT401-NFT499**: Performance hints
- **NFT501-NFT599**: Formatting issues
- **NFT601-NFT699**: nftables-specific validations
## Development
@ -196,8 +500,95 @@ table inet protection {
}
```
## Diagnostics Examples
### Error Detection
Input file with issues:
```nftables
table inet firewall {
chain input {
type filter hook input priority 100
tcp dport 22 accept
}
}
```
Human-readable output:
```
Found 2 issues in config.nft:
config.nft:3:37: error: Expected ';' after policy [NFT001]
1: table inet firewall {
2: chain input {
→ 3: type filter hook input priority 100
4: tcp dport 22 accept
5: }
config.nft:3:1: warning: Filter chain should have an explicit policy [NFT301]
1: table inet firewall {
2: chain input {
→ 3: type filter hook input priority 100
4: tcp dport 22 accept
5: }
```
JSON output:
```json
{
"diagnostics": [
{
"range": {
"start": { "line": 2, "character": 37 },
"end": { "line": 2, "character": 37 }
},
"severity": "Error",
"code": "NFT001",
"source": "nff",
"message": "Expected ';' after policy"
},
{
"range": {
"start": { "line": 2, "character": 0 },
"end": { "line": 2, "character": 37 }
},
"severity": "Warning",
"code": "NFT301",
"source": "nff",
"message": "Filter chain should have an explicit policy"
}
],
"file_path": "config.nft",
"source_text": "..."
}
```
### Style Analysis
Input with style issues:
```nftables
table inet test{chain input{type filter hook input priority 0;policy drop;tcp dport 22 accept;}}
```
Style warnings:
```
Found 3 issues in style.nft:
style.nft:1:1: warning: Consider adding a shebang line [NFT201]
style.nft:1:121: warning: Line too long (98 > 80 characters) [NFT205]
style.nft:1:16: warning: Missing space after '{' [NFT503]
```
## Contributing
### Building
Build with `cargo build` as usual. If you are using Nix, you will also want to
ensure that the Nix package builds as expected.
### Code Style
- Follow `cargo fmt` formatting
@ -212,11 +603,6 @@ table inet protection {
- **Regression tests**: Known issue prevention
- **Performance tests**: Benchmark critical paths
### Building
Build with `cargo build` as usual. If you are using Nix, you will also want to
ensure that the Nix package builds as expected.
## Technical Notes
### CST Implementation
@ -237,6 +623,17 @@ Below are the design goals of nff's architechture.
- **Memory efficiency**: Streaming token processing where possible
- **Grammar completeness**: Covers full nftables syntax specification
### Diagnostic Architecture
The diagnostic system uses a modular architecture with specialized analyzers:
- **Modular design**: Each analyzer focuses on specific concerns (lexical,
syntax, style, semantic)
- **Configurable analysis**: Enable/disable specific diagnostic categories
- **LSP compatibility**: JSON output follows Language Server Protocol standards
- **Performance optimized**: Concurrent analysis when possible
- **Extensible**: Easy to add new diagnostic rules and categories
## License
nff is licensed under [MPL v2.0](LICENSE). See license file for more details on

View file

@ -4,6 +4,8 @@
rustfmt,
clippy,
cargo,
cargo-machete,
cargo-nextest,
rustPlatform,
}:
mkShell {
@ -13,6 +15,8 @@ mkShell {
rustfmt
clippy
cargo
cargo-machete
cargo-nextest
];
RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";

View file

@ -125,6 +125,12 @@ pub enum Expression {
// Set expressions
Set(Vec<Expression>),
// Vmap expressions (value maps)
Vmap {
expr: Option<Box<Expression>>,
map: Vec<(Expression, Expression)>,
},
// Range expressions
Range {
start: Box<Expression>,

View file

@ -4,250 +4,289 @@
use crate::lexer::{Token, TokenKind};
use cstree::{RawSyntaxKind, green::GreenNode, util::NodeOrToken};
use num_enum::{IntoPrimitive, TryFromPrimitive};
use std::fmt;
use thiserror::Error;
/// nftables syntax node types
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
/// Uses `TryFromPrimitive` for safe conversion from raw values with fallback to `Error`.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, TryFromPrimitive, IntoPrimitive,
)]
#[repr(u16)]
pub enum SyntaxKind {
// Root and containers
Root = 0,
Table,
Chain,
Rule,
Set,
Map,
Element,
Table = 1,
Chain = 2,
Rule = 3,
Set = 4,
Map = 5,
Element = 6,
// Expressions
Expression,
BinaryExpr,
UnaryExpr,
CallExpr,
SetExpr,
RangeExpr,
Expression = 7,
BinaryExpr = 8,
UnaryExpr = 9,
CallExpr = 10,
SetExpr = 11,
RangeExpr = 12,
// Statements
Statement,
IncludeStmt,
DefineStmt,
FlushStmt,
AddStmt,
DeleteStmt,
Statement = 13,
IncludeStmt = 14,
DefineStmt = 15,
FlushStmt = 16,
AddStmt = 17,
DeleteStmt = 18,
// Literals and identifiers
Identifier,
StringLiteral,
NumberLiteral,
IpAddress,
Ipv6Address,
MacAddress,
Identifier = 19,
StringLiteral = 20,
NumberLiteral = 21,
IpAddress = 22,
Ipv6Address = 23,
MacAddress = 24,
// Keywords
TableKw,
ChainKw,
RuleKw,
SetKw,
MapKw,
ElementKw,
IncludeKw,
DefineKw,
FlushKw,
AddKw,
DeleteKw,
InsertKw,
ReplaceKw,
TableKw = 25,
ChainKw = 26,
RuleKw = 27,
SetKw = 28,
MapKw = 29,
ElementKw = 30,
IncludeKw = 31,
DefineKw = 32,
FlushKw = 33,
AddKw = 34,
DeleteKw = 35,
InsertKw = 36,
ReplaceKw = 37,
// Chain types and hooks
FilterKw,
NatKw,
RouteKw,
InputKw,
OutputKw,
ForwardKw,
PreroutingKw,
PostroutingKw,
FilterKw = 38,
NatKw = 39,
RouteKw = 40,
InputKw = 41,
OutputKw = 42,
ForwardKw = 43,
PreroutingKw = 44,
PostroutingKw = 45,
// Protocols and families
IpKw,
Ip6Kw,
InetKw,
ArpKw,
BridgeKw,
NetdevKw,
TcpKw,
UdpKw,
IcmpKw,
Icmpv6Kw,
IpKw = 46,
Ip6Kw = 47,
InetKw = 48,
ArpKw = 49,
BridgeKw = 50,
NetdevKw = 51,
TcpKw = 52,
UdpKw = 53,
IcmpKw = 54,
Icmpv6Kw = 55,
// Match keywords
SportKw,
DportKw,
SaddrKw,
DaddrKw,
ProtocolKw,
NexthdrKw,
TypeKw,
HookKw,
PriorityKw,
PolicyKw,
IifnameKw,
OifnameKw,
CtKw,
StateKw,
SportKw = 56,
DportKw = 57,
SaddrKw = 58,
DaddrKw = 59,
ProtocolKw = 60,
NexthdrKw = 61,
TypeKw = 62,
HookKw = 63,
PriorityKw = 64,
PolicyKw = 65,
IifnameKw = 66,
OifnameKw = 67,
CtKw = 68,
StateKw = 69,
// Actions
AcceptKw,
DropKw,
RejectKw,
ReturnKw,
JumpKw,
GotoKw,
ContinueKw,
LogKw,
CommentKw,
AcceptKw = 70,
DropKw = 71,
RejectKw = 72,
ReturnKw = 73,
JumpKw = 74,
GotoKw = 75,
ContinueKw = 76,
LogKw = 77,
CommentKw = 78,
// States
EstablishedKw,
RelatedKw,
NewKw,
InvalidKw,
EstablishedKw = 79,
RelatedKw = 80,
NewKw = 81,
InvalidKw = 82,
// Operators
EqOp,
NeOp,
LeOp,
GeOp,
LtOp,
GtOp,
EqOp = 83,
NeOp = 84,
LeOp = 85,
GeOp = 86,
LtOp = 87,
GtOp = 88,
// Punctuation
LeftBrace,
RightBrace,
LeftParen,
RightParen,
LeftBracket,
RightBracket,
Comma,
Semicolon,
Colon,
Assign,
Dash,
Slash,
Dot,
LeftBrace = 89,
RightBrace = 90,
LeftParen = 91,
RightParen = 92,
LeftBracket = 93,
RightBracket = 94,
Comma = 95,
Semicolon = 96,
Colon = 97,
Assign = 98,
Dash = 99,
Slash = 100,
Dot = 101,
// Trivia
Whitespace,
Newline,
Comment,
Shebang,
Whitespace = 102,
Newline = 103,
Comment = 104,
Shebang = 105,
// Error recovery
Error,
Error = 106,
// Protocol keywords for nftables
VmapKw = 107,
NdRouterAdvertKw = 108,
NdNeighborSolicitKw = 109,
NdNeighborAdvertKw = 110,
EchoRequestKw = 111,
DestUnreachableKw = 112,
RouterAdvertisementKw = 113,
TimeExceededKw = 114,
ParameterProblemKw = 115,
PacketTooBigKw = 116,
}
impl From<TokenKind> for SyntaxKind {
fn from(kind: TokenKind) -> Self {
use TokenKind::*;
match kind {
TokenKind::Table => SyntaxKind::TableKw,
TokenKind::Chain => SyntaxKind::ChainKw,
TokenKind::Rule => SyntaxKind::RuleKw,
TokenKind::Set => SyntaxKind::SetKw,
TokenKind::Map => SyntaxKind::MapKw,
TokenKind::Element => SyntaxKind::ElementKw,
TokenKind::Include => SyntaxKind::IncludeKw,
TokenKind::Define => SyntaxKind::DefineKw,
TokenKind::Flush => SyntaxKind::FlushKw,
TokenKind::Add => SyntaxKind::AddKw,
TokenKind::Delete => SyntaxKind::DeleteKw,
TokenKind::Insert => SyntaxKind::InsertKw,
TokenKind::Replace => SyntaxKind::ReplaceKw,
// Keywords -> Kw variants
Table => SyntaxKind::TableKw,
Chain => SyntaxKind::ChainKw,
Rule => SyntaxKind::RuleKw,
Set => SyntaxKind::SetKw,
Map => SyntaxKind::MapKw,
Element => SyntaxKind::ElementKw,
Include => SyntaxKind::IncludeKw,
Define => SyntaxKind::DefineKw,
Flush => SyntaxKind::FlushKw,
Add => SyntaxKind::AddKw,
Delete => SyntaxKind::DeleteKw,
Insert => SyntaxKind::InsertKw,
Replace => SyntaxKind::ReplaceKw,
TokenKind::Filter => SyntaxKind::FilterKw,
TokenKind::Nat => SyntaxKind::NatKw,
TokenKind::Route => SyntaxKind::RouteKw,
// Chain types and hooks
Filter => SyntaxKind::FilterKw,
Nat => SyntaxKind::NatKw,
Route => SyntaxKind::RouteKw,
Input => SyntaxKind::InputKw,
Output => SyntaxKind::OutputKw,
Forward => SyntaxKind::ForwardKw,
Prerouting => SyntaxKind::PreroutingKw,
Postrouting => SyntaxKind::PostroutingKw,
TokenKind::Input => SyntaxKind::InputKw,
TokenKind::Output => SyntaxKind::OutputKw,
TokenKind::Forward => SyntaxKind::ForwardKw,
TokenKind::Prerouting => SyntaxKind::PreroutingKw,
TokenKind::Postrouting => SyntaxKind::PostroutingKw,
// Protocols and families
Ip => SyntaxKind::IpKw,
Ip6 => SyntaxKind::Ip6Kw,
Inet => SyntaxKind::InetKw,
Arp => SyntaxKind::ArpKw,
Bridge => SyntaxKind::BridgeKw,
Netdev => SyntaxKind::NetdevKw,
Tcp => SyntaxKind::TcpKw,
Udp => SyntaxKind::UdpKw,
Icmp => SyntaxKind::IcmpKw,
Icmpv6 => SyntaxKind::Icmpv6Kw,
TokenKind::Ip => SyntaxKind::IpKw,
TokenKind::Ip6 => SyntaxKind::Ip6Kw,
TokenKind::Inet => SyntaxKind::InetKw,
TokenKind::Arp => SyntaxKind::ArpKw,
TokenKind::Bridge => SyntaxKind::BridgeKw,
TokenKind::Netdev => SyntaxKind::NetdevKw,
TokenKind::Tcp => SyntaxKind::TcpKw,
TokenKind::Udp => SyntaxKind::UdpKw,
TokenKind::Icmp => SyntaxKind::IcmpKw,
TokenKind::Icmpv6 => SyntaxKind::Icmpv6Kw,
// Match keywords
Sport => SyntaxKind::SportKw,
Dport => SyntaxKind::DportKw,
Saddr => SyntaxKind::SaddrKw,
Daddr => SyntaxKind::DaddrKw,
Protocol => SyntaxKind::ProtocolKw,
Nexthdr => SyntaxKind::NexthdrKw,
Type => SyntaxKind::TypeKw,
Hook => SyntaxKind::HookKw,
Priority => SyntaxKind::PriorityKw,
Policy => SyntaxKind::PolicyKw,
Iifname => SyntaxKind::IifnameKw,
Oifname => SyntaxKind::OifnameKw,
Ct => SyntaxKind::CtKw,
State => SyntaxKind::StateKw,
TokenKind::Sport => SyntaxKind::SportKw,
TokenKind::Dport => SyntaxKind::DportKw,
TokenKind::Saddr => SyntaxKind::SaddrKw,
TokenKind::Daddr => SyntaxKind::DaddrKw,
TokenKind::Protocol => SyntaxKind::ProtocolKw,
TokenKind::Nexthdr => SyntaxKind::NexthdrKw,
TokenKind::Type => SyntaxKind::TypeKw,
TokenKind::Hook => SyntaxKind::HookKw,
TokenKind::Priority => SyntaxKind::PriorityKw,
TokenKind::Policy => SyntaxKind::PolicyKw,
TokenKind::Iifname => SyntaxKind::IifnameKw,
TokenKind::Oifname => SyntaxKind::OifnameKw,
TokenKind::Ct => SyntaxKind::CtKw,
TokenKind::State => SyntaxKind::StateKw,
// Actions
Accept => SyntaxKind::AcceptKw,
Drop => SyntaxKind::DropKw,
Reject => SyntaxKind::RejectKw,
Return => SyntaxKind::ReturnKw,
Jump => SyntaxKind::JumpKw,
Goto => SyntaxKind::GotoKw,
Continue => SyntaxKind::ContinueKw,
Log => SyntaxKind::LogKw,
Comment => SyntaxKind::CommentKw,
TokenKind::Accept => SyntaxKind::AcceptKw,
TokenKind::Drop => SyntaxKind::DropKw,
TokenKind::Reject => SyntaxKind::RejectKw,
TokenKind::Return => SyntaxKind::ReturnKw,
TokenKind::Jump => SyntaxKind::JumpKw,
TokenKind::Goto => SyntaxKind::GotoKw,
TokenKind::Continue => SyntaxKind::ContinueKw,
TokenKind::Log => SyntaxKind::LogKw,
TokenKind::Comment => SyntaxKind::CommentKw,
// States
Established => SyntaxKind::EstablishedKw,
Related => SyntaxKind::RelatedKw,
New => SyntaxKind::NewKw,
Invalid => SyntaxKind::InvalidKw,
TokenKind::Established => SyntaxKind::EstablishedKw,
TokenKind::Related => SyntaxKind::RelatedKw,
TokenKind::New => SyntaxKind::NewKw,
TokenKind::Invalid => SyntaxKind::InvalidKw,
// Protocol keywords for ICMP/ICMPv6
Vmap => SyntaxKind::VmapKw,
NdRouterAdvert => SyntaxKind::NdRouterAdvertKw,
NdNeighborSolicit => SyntaxKind::NdNeighborSolicitKw,
NdNeighborAdvert => SyntaxKind::NdNeighborAdvertKw,
EchoRequest => SyntaxKind::EchoRequestKw,
DestUnreachable => SyntaxKind::DestUnreachableKw,
RouterAdvertisement => SyntaxKind::RouterAdvertisementKw,
TimeExceeded => SyntaxKind::TimeExceededKw,
ParameterProblem => SyntaxKind::ParameterProblemKw,
PacketTooBig => SyntaxKind::PacketTooBigKw,
TokenKind::Eq => SyntaxKind::EqOp,
TokenKind::Ne => SyntaxKind::NeOp,
TokenKind::Le => SyntaxKind::LeOp,
TokenKind::Ge => SyntaxKind::GeOp,
TokenKind::Lt => SyntaxKind::LtOp,
TokenKind::Gt => SyntaxKind::GtOp,
// Operators - direct mapping
Eq => SyntaxKind::EqOp,
Ne => SyntaxKind::NeOp,
Le => SyntaxKind::LeOp,
Ge => SyntaxKind::GeOp,
Lt => SyntaxKind::LtOp,
Gt => SyntaxKind::GtOp,
TokenKind::LeftBrace => SyntaxKind::LeftBrace,
TokenKind::RightBrace => SyntaxKind::RightBrace,
TokenKind::LeftParen => SyntaxKind::LeftParen,
TokenKind::RightParen => SyntaxKind::RightParen,
TokenKind::LeftBracket => SyntaxKind::LeftBracket,
TokenKind::RightBracket => SyntaxKind::RightBracket,
TokenKind::Comma => SyntaxKind::Comma,
TokenKind::Semicolon => SyntaxKind::Semicolon,
TokenKind::Colon => SyntaxKind::Colon,
TokenKind::Assign => SyntaxKind::Assign,
TokenKind::Dash => SyntaxKind::Dash,
TokenKind::Slash => SyntaxKind::Slash,
TokenKind::Dot => SyntaxKind::Dot,
// Punctuation - direct mapping
LeftBrace => SyntaxKind::LeftBrace,
RightBrace => SyntaxKind::RightBrace,
LeftParen => SyntaxKind::LeftParen,
RightParen => SyntaxKind::RightParen,
LeftBracket => SyntaxKind::LeftBracket,
RightBracket => SyntaxKind::RightBracket,
Comma => SyntaxKind::Comma,
Semicolon => SyntaxKind::Semicolon,
Colon => SyntaxKind::Colon,
Assign => SyntaxKind::Assign,
Dash => SyntaxKind::Dash,
Slash => SyntaxKind::Slash,
Dot => SyntaxKind::Dot,
TokenKind::StringLiteral(_) => SyntaxKind::StringLiteral,
TokenKind::NumberLiteral(_) => SyntaxKind::NumberLiteral,
TokenKind::IpAddress(_) => SyntaxKind::IpAddress,
TokenKind::Ipv6Address(_) => SyntaxKind::Ipv6Address,
TokenKind::MacAddress(_) => SyntaxKind::MacAddress,
TokenKind::Identifier(_) => SyntaxKind::Identifier,
// Literals - map data-carrying variants to their types
StringLiteral(_) => SyntaxKind::StringLiteral,
NumberLiteral(_) => SyntaxKind::NumberLiteral,
IpAddress(_) => SyntaxKind::IpAddress,
Ipv6Address(_) => SyntaxKind::Ipv6Address,
MacAddress(_) => SyntaxKind::MacAddress,
Identifier(_) => SyntaxKind::Identifier,
TokenKind::Newline => SyntaxKind::Newline,
TokenKind::CommentLine(_) => SyntaxKind::Comment,
TokenKind::Shebang(_) => SyntaxKind::Shebang,
// Special tokens
Newline => SyntaxKind::Newline,
CommentLine(_) => SyntaxKind::Comment,
Shebang(_) => SyntaxKind::Shebang,
TokenKind::Error => SyntaxKind::Error,
// Error fallback
Error => SyntaxKind::Error,
}
}
}
@ -301,7 +340,7 @@ impl SyntaxKind {
}
pub fn from_raw(raw: RawSyntaxKind) -> Self {
unsafe { std::mem::transmute(raw.0 as u16) }
Self::try_from(raw.0 as u16).unwrap_or(SyntaxKind::Error)
}
}
@ -319,7 +358,7 @@ pub enum CstError {
/// Result type for CST operations
pub type CstResult<T> = Result<T, CstError>;
/// Basic CST builder
/// CST builder for nftables syntax
pub struct CstBuilder;
impl CstBuilder {
@ -342,6 +381,79 @@ impl CstBuilder {
Self::validate_tree(&tree)?;
Ok(tree)
}
/// Display CST in a human-readable format for debugging
pub fn display_tree(node: &GreenNode, tree_format: bool, verbose: bool) -> String {
let mut output = String::new();
Self::display_node_recursive(node, 0, tree_format, verbose, &mut output);
output
}
fn display_node_recursive(
node: &GreenNode,
indent_level: usize,
tree_format: bool,
verbose: bool,
output: &mut String,
) {
let kind = SyntaxKind::from_raw(node.kind());
let indent = if tree_format {
" ".repeat(indent_level)
} else {
String::new()
};
if tree_format {
output.push_str(&format!("{}├─ {}", indent, kind));
} else {
output.push_str(&format!("{}{}", indent, kind));
}
if verbose {
output.push_str(&format!(
" (kind: {:?}, width: {:?})",
node.kind(),
node.text_len()
));
}
output.push('\n');
// Display children
for child in node.children() {
match child {
NodeOrToken::Node(child_node) => {
Self::display_node_recursive(
child_node,
indent_level + 1,
tree_format,
verbose,
output,
);
}
NodeOrToken::Token(token) => {
let token_indent = if tree_format {
" ".repeat(indent_level + 1)
} else {
String::new()
};
let token_kind = SyntaxKind::from_raw(token.kind());
if tree_format {
output.push_str(&format!("{}├─ {}", token_indent, token_kind));
} else {
output.push_str(&format!("{}{}", token_indent, token_kind));
}
if verbose {
output.push_str(&format!(" (width: {:?})", token.text_len()));
}
output.push('\n');
}
}
}
}
}
/// Internal tree builder that constructs CST according to nftables grammar
@ -1208,7 +1320,7 @@ mod tests {
let mut lexer = NftablesLexer::new(source);
let tokens = lexer.tokenize().expect("Tokenization should succeed");
// CST is now implemented - test that it works
// Test CST construction with basic table syntax
let green_tree = CstBuilder::build_tree(&tokens);
// Verify the tree was created successfully
@ -1225,4 +1337,88 @@ mod tests {
let cst_result = CstBuilder::parse_to_cst(&tokens);
assert!(cst_result.is_ok());
}
#[test]
fn test_num_enum_improvements() {
// Test that from_raw uses num_enum for conversion
// Invalid values fall back to Error variant
// Test valid conversions
assert_eq!(SyntaxKind::from_raw(RawSyntaxKind(0)), SyntaxKind::Root);
assert_eq!(SyntaxKind::from_raw(RawSyntaxKind(1)), SyntaxKind::Table);
assert_eq!(SyntaxKind::from_raw(RawSyntaxKind(25)), SyntaxKind::TableKw);
assert_eq!(SyntaxKind::from_raw(RawSyntaxKind(106)), SyntaxKind::Error);
assert_eq!(
SyntaxKind::from_raw(RawSyntaxKind(116)),
SyntaxKind::PacketTooBigKw
);
// Test invalid values automatically fall back to Error
assert_eq!(SyntaxKind::from_raw(RawSyntaxKind(999)), SyntaxKind::Error);
assert_eq!(SyntaxKind::from_raw(RawSyntaxKind(1000)), SyntaxKind::Error);
// Test bidirectional conversion
for variant in [
SyntaxKind::Root,
SyntaxKind::Table,
SyntaxKind::TableKw,
SyntaxKind::Error,
SyntaxKind::PacketTooBigKw,
] {
let raw = variant.to_raw();
let converted_back = SyntaxKind::from_raw(raw);
assert_eq!(variant, converted_back);
}
}
#[test]
fn test_token_kind_conversion_improvements() {
// Test that From<TokenKind> conversion is complete and correct
use crate::lexer::TokenKind;
// Test keyword mappings
assert_eq!(SyntaxKind::from(TokenKind::Table), SyntaxKind::TableKw);
assert_eq!(SyntaxKind::from(TokenKind::Chain), SyntaxKind::ChainKw);
assert_eq!(SyntaxKind::from(TokenKind::Accept), SyntaxKind::AcceptKw);
// Test operators
assert_eq!(SyntaxKind::from(TokenKind::Eq), SyntaxKind::EqOp);
assert_eq!(SyntaxKind::from(TokenKind::Lt), SyntaxKind::LtOp);
// Test punctuation
assert_eq!(
SyntaxKind::from(TokenKind::LeftBrace),
SyntaxKind::LeftBrace
);
assert_eq!(
SyntaxKind::from(TokenKind::Semicolon),
SyntaxKind::Semicolon
);
// Test literals (with data)
assert_eq!(
SyntaxKind::from(TokenKind::StringLiteral("test".to_string())),
SyntaxKind::StringLiteral
);
assert_eq!(
SyntaxKind::from(TokenKind::NumberLiteral(42)),
SyntaxKind::NumberLiteral
);
assert_eq!(
SyntaxKind::from(TokenKind::IpAddress("192.168.1.1".to_string())),
SyntaxKind::IpAddress
);
assert_eq!(
SyntaxKind::from(TokenKind::Identifier("test".to_string())),
SyntaxKind::Identifier
);
// Test special tokens
assert_eq!(SyntaxKind::from(TokenKind::Newline), SyntaxKind::Newline);
assert_eq!(
SyntaxKind::from(TokenKind::CommentLine("# comment".to_string())),
SyntaxKind::Comment
);
assert_eq!(SyntaxKind::from(TokenKind::Error), SyntaxKind::Error);
}
}

1622
src/diagnostic.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,8 +10,8 @@ pub enum LexError {
InvalidToken { position: usize, text: String },
#[error("Unterminated string literal starting at position {position}")]
UnterminatedString { position: usize },
#[error("Invalid numeric literal: {text}")]
InvalidNumber { text: String },
#[error("Invalid numeric literal at position {position}: {text}")]
InvalidNumber { position: usize, text: String },
}
/// Result type for lexical analysis
@ -129,6 +129,28 @@ pub enum TokenKind {
#[token("new")]
New,
// Additional protocol keywords
#[token("vmap")]
Vmap,
#[token("nd-router-advert")]
NdRouterAdvert,
#[token("nd-neighbor-solicit")]
NdNeighborSolicit,
#[token("nd-neighbor-advert")]
NdNeighborAdvert,
#[token("echo-request")]
EchoRequest,
#[token("destination-unreachable")]
DestUnreachable,
#[token("router-advertisement")]
RouterAdvertisement,
#[token("time-exceeded")]
TimeExceeded,
#[token("parameter-problem")]
ParameterProblem,
#[token("packet-too-big")]
PacketTooBig,
// Actions
#[token("accept")]
Accept,
@ -334,6 +356,7 @@ impl<'a> NftablesLexer<'a> {
.any(|c| !c.is_ascii_digit() && c != '.' && c != 'x' && c != 'X')
{
return Err(LexError::InvalidNumber {
position: span.start,
text: text.to_owned(),
});
} else {
@ -426,4 +449,38 @@ mod tests {
panic!("Expected InvalidToken error");
}
}
#[test]
fn test_invalid_number_with_position() {
// Test that we can create a proper diagnostic with position information
use crate::diagnostic::LexicalAnalyzer;
// Create a source with the same invalid pattern at different positions
let source = "123abc normal 123abc end";
// Since normal tokenization splits "123abc" into "123" + "abc",
// let's test the diagnostic creation directly with a mock error
let error1 = LexError::InvalidNumber {
position: 0,
text: "123abc".to_string(),
};
let error2 = LexError::InvalidNumber {
position: 14,
text: "123abc".to_string(),
};
// Test that diagnostics are created with correct positions
let diagnostic1 = LexicalAnalyzer::lex_error_to_diagnostic(&error1, source);
let diagnostic2 = LexicalAnalyzer::lex_error_to_diagnostic(&error2, source);
// First occurrence should be at position 0
assert_eq!(diagnostic1.range.start.line, 0);
assert_eq!(diagnostic1.range.start.character, 0);
assert_eq!(diagnostic1.message, "Invalid number: '123abc'");
// Second occurrence should be at position 14 (not 0)
assert_eq!(diagnostic2.range.start.line, 0);
assert_eq!(diagnostic2.range.start.character, 14);
assert_eq!(diagnostic2.message, "Invalid number: '123abc'");
}
}

View file

@ -1,18 +1,21 @@
mod ast;
mod cst;
mod diagnostic;
mod lexer;
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;
use thiserror::Error;
use crate::cst::CstBuilder;
use crate::lexer::NftablesLexer;
use crate::diagnostic::{DiagnosticAnalyzer, DiagnosticConfig};
use crate::lexer::{NftablesLexer, Token, TokenKind};
use crate::parser::Parser as NftablesParser;
use crate::syntax::{FormatConfig, IndentStyle, NftablesFormatter};
@ -24,51 +27,213 @@ enum FormatterError {
InvalidFile(String),
#[error("Parse error: {0}")]
ParseError(String),
#[error("Syntax error at line {line}, column {column}: {message}")]
SyntaxError {
line: usize,
column: usize,
message: String,
suggestion: Option<String>,
},
#[error("IO error: {0}")]
Io(#[from] io::Error),
}
#[derive(Parser, Debug)]
#[derive(Error, Debug)]
enum LintError {
#[error("Lint errors found in {file_count} file(s)")]
DiagnosticErrors { file_count: usize },
#[error("File discovery error: {0}")]
FileDiscovery(#[from] anyhow::Error),
}
#[derive(Parser, Debug, Clone)]
#[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,
}
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, action = clap::ArgAction::Set, default_value = "true")]
style_warnings: bool,
/// Include best practice recommendations in diagnostics
#[arg(long, action = clap::ArgAction::Set, default_value = "true")]
best_practices: bool,
/// Include performance hints in diagnostics
#[arg(long, action = clap::ArgAction::Set, default_value = "true")]
performance_hints: bool,
/// Include security warnings in diagnostics
#[arg(long, action = clap::ArgAction::Set, default_value = "true")]
security_warnings: bool,
/// Diagnostic modules to run (comma-separated: lexical,syntax,style,semantic)
#[arg(long, value_delimiter = ',')]
modules: Option<Vec<String>>,
},
/// Parse and display file in CST format for debugging
Parse {
/// nftables config file to parse
#[arg(value_name = "FILE")]
file: String,
/// Show tree structure with indentation
#[arg(long)]
tree: bool,
/// Show detailed node information
#[arg(long)]
verbose: bool,
},
}
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() {
@ -76,12 +241,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 {
@ -90,7 +255,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!(
@ -117,7 +282,7 @@ fn process_nftables_config(args: Args) -> Result<()> {
}
// Parse
let ruleset = if args.debug {
let ruleset = if debug {
// Use error-recovery parsing for debug mode
let (parsed_ruleset, errors) = NftablesParser::parse_with_errors(&source);
if !errors.is_empty() {
@ -127,64 +292,524 @@ fn process_nftables_config(args: Args) -> Result<()> {
}
eprintln!();
}
parsed_ruleset.unwrap_or_else(|| crate::ast::Ruleset::new())
parsed_ruleset.unwrap_or_else(crate::ast::Ruleset::new)
} else {
let mut parser = NftablesParser::new(tokens.clone());
parser
.parse()
.map_err(|e| FormatterError::ParseError(e.to_string()))?
.map_err(|e| convert_parse_error_to_formatter_error(&e, &source, &tokens))?
};
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;
let mut error_file_count = 0;
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);
error_file_count += 1;
if !is_multiple_files {
return Err(e);
}
}
}
if error_file_count > 0 {
return Err(LintError::DiagnosticErrors {
file_count: error_file_count,
}
.into());
}
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());
}
// Return error if there are diagnostics errors
if diagnostics.has_errors() {
return Err(anyhow::anyhow!("Diagnostics found errors in file"));
}
Ok(())
}
/// Convert parser errors to formatter errors with proper location information
fn convert_parse_error_to_formatter_error(
error: &crate::parser::ParseError,
source: &str,
tokens: &[Token],
) -> FormatterError {
use crate::parser::ParseError;
match error {
ParseError::UnexpectedToken {
line,
column,
expected,
found,
} => FormatterError::SyntaxError {
line: *line,
column: *column,
message: format!("Expected {}, found '{}'", expected, found),
suggestion: None,
},
ParseError::MissingToken { expected } => {
let (line, column) = if let Some(last_token) = tokens.last() {
position_from_range(&last_token.range, source)
} else {
(1, 1)
};
FormatterError::SyntaxError {
line,
column,
message: format!("Missing token: expected {}", expected),
suggestion: None,
}
}
ParseError::InvalidExpression { message } => {
let (line, column) = find_current_parse_position(tokens, source);
FormatterError::SyntaxError {
line,
column,
message: format!("Invalid expression: {}", message),
suggestion: None,
}
}
ParseError::InvalidStatement { message } => {
let (line, column) = find_current_parse_position(tokens, source);
FormatterError::SyntaxError {
line,
column,
message: format!("Invalid statement: {}", message),
suggestion: None,
}
}
ParseError::SemanticError { message } => {
let (line, column) = find_current_parse_position(tokens, source);
FormatterError::SyntaxError {
line,
column,
message: format!("Semantic error: {}", message),
suggestion: None,
}
}
ParseError::LexError(lex_error) => {
// Convert lexical errors to formatter errors with location
convert_lex_error_to_formatter_error(lex_error, source)
}
ParseError::AnyhowError(anyhow_error) => {
// For anyhow errors, try to extract location from error message and context
let error_msg = anyhow_error.to_string();
let (line, column) = find_error_location_from_context(&error_msg, tokens, source);
let suggestion = generate_suggestion_for_error(&error_msg);
FormatterError::SyntaxError {
line,
column,
message: error_msg,
suggestion,
}
}
}
}
/// Find the current parsing position from tokens
fn find_current_parse_position(tokens: &[Token], source: &str) -> (usize, usize) {
// Look for the last non-whitespace, non-comment token
for token in tokens.iter().rev() {
match token.kind {
TokenKind::Newline | TokenKind::CommentLine(_) => continue,
_ => return position_from_range(&token.range, source),
}
}
(1, 1) // fallback
}
/// Convert lexical errors to formatter errors
fn convert_lex_error_to_formatter_error(
lex_error: &crate::lexer::LexError,
source: &str,
) -> FormatterError {
use crate::lexer::LexError;
match lex_error {
LexError::InvalidToken { position, text } => {
let (line, column) = offset_to_line_column(*position, source);
FormatterError::SyntaxError {
line,
column,
message: format!("Invalid token: '{}'", text),
suggestion: None,
}
}
LexError::UnterminatedString { position } => {
let (line, column) = offset_to_line_column(*position, source);
FormatterError::SyntaxError {
line,
column,
message: "Unterminated string literal".to_string(),
suggestion: Some("Add closing quote".to_string()),
}
}
LexError::InvalidNumber { position, text } => {
let (line, column) = offset_to_line_column(*position, source);
FormatterError::SyntaxError {
line,
column,
message: format!("Invalid number: '{}'", text),
suggestion: Some("Check number format".to_string()),
}
}
}
}
/// Convert byte offset to line/column position
fn offset_to_line_column(offset: usize, source: &str) -> (usize, usize) {
let mut line = 1;
let mut column = 1;
for (i, ch) in source.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
(line, column)
}
/// Find error location from context clues in the error message
fn find_error_location_from_context(
error_msg: &str,
tokens: &[Token],
source: &str,
) -> (usize, usize) {
// Look for context clues in the error message
if error_msg.contains("Expected string or identifier, got:") {
// Find the problematic token mentioned in the error
if let Some(bad_token_text) = extract_token_from_error_message(error_msg) {
// Find this token in the token stream
for token in tokens {
if token.text == bad_token_text {
return position_from_range(&token.range, source);
}
}
}
}
// Fallback to finding last meaningful token
find_current_parse_position(tokens, source)
}
/// Extract the problematic token from error message
fn extract_token_from_error_message(error_msg: &str) -> Option<String> {
// Parse messages like "Expected string or identifier, got: {"
if let Some(got_part) = error_msg.split("got: ").nth(1) {
Some(got_part.trim().to_string())
} else {
None
}
}
/// Generate helpful suggestions based on error message
fn generate_suggestion_for_error(error_msg: &str) -> Option<String> {
if error_msg.contains("Expected string or identifier") {
Some(
"Check if you're missing quotes around a string value or have an unexpected character"
.to_string(),
)
} else if error_msg.contains("Expected") && error_msg.contains("got:") {
Some("Check syntax and ensure proper nftables structure".to_string())
} else {
None
}
}
/// Convert TextRange to line/column position
fn position_from_range(range: &text_size::TextRange, source: &str) -> (usize, usize) {
let start_offset: usize = range.start().into();
let lines: Vec<&str> = source.lines().collect();
let mut current_offset = 0;
for (line_idx, line) in lines.iter().enumerate() {
let line_end = current_offset + line.len();
if start_offset <= line_end {
let column = start_offset - current_offset;
return (line_idx + 1, column + 1); // 1-based indexing
}
current_offset = line_end + 1; // +1 for newline
}
(1, 1) // fallback
}
fn process_parse_command(file: String, tree: bool, verbose: bool, debug: bool) -> Result<()> {
let source =
fs::read_to_string(&file).with_context(|| format!("Failed to read file: {}", file))?;
// Tokenize
let mut lexer = NftablesLexer::new(&source);
let tokens = lexer
.tokenize()
.map_err(|e| FormatterError::ParseError(format!("Tokenization failed: {}", e)))?;
if debug {
eprintln!("=== TOKENS ===");
for (i, token) in tokens.iter().enumerate() {
eprintln!(
"{:3}: {:?} @ {:?} = '{}'",
i, token.kind, token.range, token.text
);
}
eprintln!();
}
// Build CST
let cst_tree = CstBuilder::build_tree(&tokens);
// Validate CST
match CstBuilder::validate_tree(&cst_tree) {
Ok(()) => {
if debug {
eprintln!("CST validation passed");
eprintln!();
}
}
Err(e) => {
eprintln!("Warning: CST validation error: {}", e);
eprintln!();
}
}
// Display CST
let cst_display = CstBuilder::display_tree(&cst_tree, tree, verbose);
println!("{}", cst_display);
Ok(())
}
fn main() -> Result<()> {
let args = Args::parse();
if let Err(e) = process_nftables_config(args) {
eprintln!("Error: {}", e);
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,
),
Commands::Parse {
file,
tree,
verbose,
} => process_parse_command(file.clone(), *tree, *verbose, args.debug),
};
// Print the error chain
let mut current = e.source();
while let Some(cause) = current {
eprintln!(" Caused by: {}", cause);
current = cause.source();
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 {
eprintln!("Error: {}", e);
// Print the error chain
let mut current = e.source();
while let Some(cause) = current {
eprintln!(" Caused by: {}", cause);
current = cause.source();
}
}
std::process::exit(1);

View file

@ -311,9 +311,8 @@ impl Parser {
self.advance(); // consume 'policy'
let policy = self.parse_policy()?;
chain = chain.with_policy(policy);
self.consume(TokenKind::Semicolon, "Expected ';' after policy")?;
}
self.consume(TokenKind::Semicolon, "Expected ';' after policy")?;
}
Some(TokenKind::CommentLine(_)) => {
self.advance();
@ -465,7 +464,13 @@ impl Parser {
fn parse_comparison_expression(&mut self) -> Result<Expression> {
let mut expr = self.parse_range_expression()?;
// Check for operators
while let Some(token) = self.peek() {
if matches!(token.kind, TokenKind::Newline) {
self.advance();
continue;
}
// Check for comparison operators
let operator = match &token.kind {
TokenKind::Eq => BinaryOperator::Eq,
TokenKind::Ne => BinaryOperator::Ne,
@ -473,7 +478,49 @@ impl Parser {
TokenKind::Le => BinaryOperator::Le,
TokenKind::Gt => BinaryOperator::Gt,
TokenKind::Ge => BinaryOperator::Ge,
_ => break,
_ => {
// Check for vmap after an expression
if matches!(&token.kind, TokenKind::Vmap) {
self.advance(); // consume 'vmap'
// Parse the map contents
self.consume(TokenKind::LeftBrace, "Expected '{' after vmap")?;
let mut map = Vec::new();
while !self.current_token_is(&TokenKind::RightBrace) && !self.is_at_end() {
// Skip commas and newlines
if self.current_token_is(&TokenKind::Comma)
|| self.current_token_is(&TokenKind::Newline)
{
self.advance();
continue;
}
// Parse key
let key = self.parse_expression()?;
// Parse colon separator
self.consume(TokenKind::Colon, "Expected ':' in vmap key-value pair")?;
// Parse value
let value = self.parse_expression()?;
// Add the key-value pair to the map
map.push((key, value));
}
self.consume(TokenKind::RightBrace, "Expected '}' to close vmap")?;
// Return a vmap expression with the previous expression as the mapping target
expr = Expression::Vmap {
expr: Some(Box::new(expr)),
map,
};
continue; // allow the outer `while` to detect ==, != … afterwards
}
break;
}
};
self.advance(); // consume operator
@ -753,6 +800,43 @@ impl Parser {
let addr = self.advance().unwrap().text.clone();
Ok(Expression::MacAddress(addr))
}
Some(TokenKind::Vmap) => {
self.advance(); // consume 'vmap'
// Parse the map contents
self.consume(TokenKind::LeftBrace, "Expected '{' after vmap")?;
let mut map = Vec::new();
while !self.current_token_is(&TokenKind::RightBrace) && !self.is_at_end() {
// Skip commas and newlines
if self.current_token_is(&TokenKind::Comma)
|| self.current_token_is(&TokenKind::Newline)
{
self.advance();
continue;
}
// Parse key
let key = self.parse_expression()?;
// Parse colon separator
self.consume(TokenKind::Colon, "Expected ':' in vmap key-value pair")?;
// Parse value
let value = self.parse_expression()?;
// Add the key-value pair to the map
map.push((key, value));
}
self.consume(TokenKind::RightBrace, "Expected '}' to close vmap")?;
// No expression available at parse time, will be filled by post-processing if needed
let expr = None;
Ok(Expression::Vmap { expr, map })
}
Some(TokenKind::LeftBrace) => {
self.advance(); // consume '{'
let mut elements = Vec::new();
@ -774,6 +858,23 @@ impl Parser {
self.consume(TokenKind::RightBrace, "Expected '}' to close set")?;
Ok(Expression::Set(elements))
}
Some(TokenKind::Accept) => {
self.advance();
Ok(Expression::Identifier("accept".to_string()))
}
Some(TokenKind::Drop) => {
self.advance();
Ok(Expression::Identifier("drop".to_string()))
}
Some(TokenKind::Reject) => {
self.advance();
Ok(Expression::Identifier("reject".to_string()))
}
Some(TokenKind::Protocol) => {
self.advance(); // consume 'protocol'
let protocol = self.parse_identifier_or_keyword()?;
Ok(Expression::Protocol(protocol))
}
_ => Err(ParseError::InvalidExpression {
message: format!(
"Unexpected token in expression: {}",

View file

@ -175,10 +175,11 @@ impl NftablesFormatter {
// Add policy on the same line if present
if let Some(policy) = &chain.policy {
write!(output, " policy {}", policy).unwrap();
output.push_str(";\n");
} else {
output.push_str("\n");
}
output.push_str(";\n");
if !chain.rules.is_empty() && !self.config.optimize {
output.push('\n');
}
@ -292,6 +293,23 @@ impl NftablesFormatter {
output.push('-');
self.format_expression(output, end);
}
Expression::Vmap { expr, map } => {
if let Some(expr) = expr {
self.format_expression(output, expr);
output.push(' ');
}
output.push_str("vmap { ");
for (i, (key, value)) in map.iter().enumerate() {
if i > 0 {
output.push_str(", ");
}
self.format_expression(output, key);
output.push_str(" : ");
self.format_expression(output, value);
}
output.push_str(" }");
}
}
}
}