A Rust library for parsing ferron.conf configuration files — a domain-specific language for custom web server configurations.
This crate provides a reference implementation of the ferron.conf format, including:
- Lexer — Tokenizes configuration files with support for comments, strings, numbers, booleans, and interpolation
- Parser — Builds an AST from tokens with full error reporting
- AST — Type-safe representation of configuration structures
For the complete format specification, see SPECIFICATION.md.
Add to your Cargo.toml:
[dependencies]
ferronconf = "0.1.0"use ferronconf::Config;
use std::str::FromStr;
let input = r#"
example.com {
root /var/www/example
tls {
provider "acme"
challenge http-01
contact "admin@example.com"
}
}
api.example.com:8080 {
proxy http://localhost:3000
}
"#;
let config = Config::from_str(input)?;The ferron.conf format supports five statement types:
Key-value pairs with optional nested blocks:
server_name example.com
max_connections 1000
enabled true
cert "{{env.TLS_CERT}}"
Configuration scoped to specific hosts (top-level only):
# Simple hostname
example.com {
root /var/www/example
}
# Wildcard subdomains
*.example.com {
tls {
provider "acme"
challenge http-01
contact "admin@example.com"
}
}
# With protocol and port
http api.example.com:8080 {
proxy http://localhost:3000
}
# IPv6
[2001:db8::1]:8080 {
root /ipv6-only
}
# Multiple hosts (comma-separated)
example.com, www.example.com {
root /var/www/shared
}
Global configuration applied to all hosts (top-level only):
{
runtime {
io_uring true
}
tcp {
listen "::"
send_buf 65536
}
default_http_port 8080
default_https_port 8443
}
Reusable configuration fragments:
snippet tls_defaults {
tls {
provider "acme"
challenge http-01
contact "admin@example.com"
}
http {
protocols h1 h2
}
}
Conditional logic based on request attributes:
match api_request {
request.uri.path ~ "/api"
request.method in "GET,POST"
}
match curl_client {
request.header.user_agent ~ "curl"
}
Supported operators: ==, !=, ~ (regex), !~ (negated regex), in
# This is a comment
server_name example.com # inline comment
Note: Comments are not allowed inside match blocks.
| Type | Example | Description |
|---|---|---|
| String (quoted) | "hello world" |
Supports escape sequences (\n, \t, \\, \") |
| String (bare) | example.com |
Unquoted alphanumeric with _-.:/+* |
| Number | 80, 3.14, -10 |
Integer or decimal |
| Boolean | true, false |
Case-sensitive literals |
| Interpolation | {{env.TLS_CERT}} |
Variable reference with dotted path |
use ferronconf::Config;
use std::str::FromStr;
let config = Config::from_str(input)?;use ferronconf::Statement;
// Find all directives with a given name
let roots = config.find_directives("root");
// Find all host blocks
let hosts = config.find_host_blocks();
// Find all match blocks
let matchers = config.find_match_blocks();
// Navigate statements
for stmt in &config.statements {
match stmt {
Statement::Directive(d) => {
println!("Directive: {}", d.name);
if let Some(root) = d.get_string_arg(0) {
println!(" Root: {}", root);
}
}
Statement::HostBlock(hb) => {
for pattern in &hb.hosts {
println!("Host: {}", pattern.as_str());
}
}
Statement::MatchBlock(mb) => {
println!("Matcher: {}", mb.matcher);
}
Statement::GlobalBlock(gb) => {
// Access global configuration
}
Statement::SnippetBlock(sb) => {
println!("Snippet: {}", sb.name);
}
}
}// Find directive inside a block
if let Some(block) = directive.block {
if let Some(nested) = block.find_directive("ssl") {
// ...
}
}use ferronconf::Value;
// From directive arguments
if let Some(root) = directive.get_string_arg(0) {
// ...
}
if let Some(port) = directive.get_integer_arg(1) {
// ...
}
if let Some(enabled) = directive.get_boolean_arg(2) {
// ...
}
// From Value directly
match &value {
Value::String(s, _) => { /* ... */ }
Value::Integer(i, _) => { /* ... */ }
Value::Float(f, _) => { /* ... */ }
Value::Boolean(b, _) => { /* ... */ }
Value::InterpolatedString(parts, _) => {
for part in parts {
match part {
StringPart::Literal(s) => { /* ... */ }
StringPart::Expression(path) => { /* ... */ }
}
}
}
}use ferronconf::ast::HostLabels;
for host_block in config.find_host_blocks() {
for pattern in &host_block.hosts {
match &pattern.labels {
HostLabels::Wildcard => { /* matches any host */ }
HostLabels::Hostname(labels) => { /* e.g., ["example", "com"] */ }
HostLabels::IpAddr(ip) => { /* IPv4 or IPv6 */ }
}
if let Some(port) = pattern.port {
// ...
}
if let Some(protocol) = &pattern.protocol {
// e.g., "http", "tcp"
}
}
// Check if block matches a specific host
if host_block.matches_host("example.com") {
// ...
}
}for match_block in config.find_match_blocks() {
println!("Matcher: {}", match_block.matcher);
for expr in &match_block.expr {
match &expr.left {
Operand::Identifier(path, _) => {
println!(" Path: {}", path.join("."));
}
Operand::String(s, _) => {
println!(" String: {}", s);
}
_ => {}
}
println!(" Operator: {}", expr.op.as_str());
// Check operator type
if expr.is_equality() { /* ... */ }
if expr.is_regex() { /* ... */ }
}
}Parse errors include line and column information:
use ferronconf::{Config, ParseError};
use std::str::FromStr;
match Config::from_str(input) {
Ok(config) => { /* ... */ }
Err(ParseError { message, span }) => {
eprintln!("Error at line {}, column {}: {}",
span.line, span.column, message);
}
}A TextMate grammar is provided in ferron.tmLanguage.json for editor syntax highlighting. Copy it to your editor's grammar directory or use it with tools like bat or syntect.
# Global defaults
{
runtime {
io_uring true
}
tcp {
listen "::"
}
default_http_port 80
default_https_port 443
admin {
listen 127.0.0.1:8081
health true
status true
}
}
# Reusable TLS configuration
snippet tls_acme {
tls {
provider "acme"
challenge http-01
contact "admin@example.com"
}
}
# Reusable HTTP settings
snippet common_http {
http {
protocols h1 h2
}
}
# Main site with static file serving
example.com:443 {
use tls_acme
use common_http
root /var/www/example
index index.html index.htm
directory_listing
compressed
log "access" {
format "combined"
}
}
# Wildcard subdomains with ACME TLS
*.example.com {
tls {
provider "acme"
challenge dns-01
contact "admin@example.com"
dns "cloudflare" {
api_key "EXAMPLE_API_KEY"
}
}
root /var/www/multi-tenant
}
# API reverse proxy
api.example.com {
proxy http://localhost:3000 http://localhost:3001 {
lb_algorithm two_random
keepalive true
http2 true
request_header +X-Real-IP "{{remote_address}}"
request_header X-Forwarded-Proto "{{scheme}}"
}
rate_limit {
rate 100
burst 50
key remote_address
}
cors {
origins "https://app.example.com"
methods GET POST PUT DELETE
headers "Content-Type" "Authorization"
credentials true
}
}
# Conditional routing
match api_request {
request.uri.path ~ "/api"
request.method in "GET,POST"
}
match curl_client {
request.header.user_agent ~ "curl"
}
# Location-based configuration
example.com {
root /var/www/example
location /static {
file_cache_control "public, max-age=31536000"
}
location /admin {
if curl_client {
status 403 {
body "Forbidden"
}
}
}
}
# Protocol-specific configuration
http * {
header X-Powered-By "Ferron"
}
# TCP service
tcp *:5432 {
proxy localhost:5432
}
- Bare strings after identifiers may be ambiguous with host blocks at the top level
- Comments are not supported inside
matchblocks - IPv4 octets are validated to be in range 0–255
MIT