pub mod ast;
pub mod context;
pub mod error;
pub mod syntax_kind;
pub mod lexer_rowan;
pub mod line_index;
pub mod parser;
pub mod rowan_to_ast;
#[cfg(feature = "wasm")]
mod wasm;
use syntax_kind::SyntaxNode;
pub fn parse_string_rowan(source: &str) -> (SyntaxNode, Vec<parser::SyntaxError>) {
let tokens = lexer_rowan::tokenize(source);
let (green, errors) = parser::parse(tokens);
(SyntaxNode::new_root(green), errors)
}
use ast::Config;
use error::{ParseError, ParseResult};
use std::fs;
use std::path::Path;
pub fn parse_config(path: &Path) -> ParseResult<Config> {
let content = fs::read_to_string(path).map_err(|e| ParseError::IoError(e.to_string()))?;
parse_string(&content)
}
pub fn parse_string(source: &str) -> ParseResult<Config> {
let (root, errors) = parse_string_rowan(source);
if let Some(err) = errors.first() {
return Err(ParseError::UnexpectedToken {
expected: "valid syntax".to_string(),
found: err.message.clone(),
position: line_index::LineIndex::new(source).position(err.offset),
});
}
Ok(rowan_to_ast::convert(&root, source))
}
pub fn parse_string_with_errors(source: &str) -> (Config, Vec<parser::SyntaxError>) {
let (root, errors) = parse_string_rowan(source);
let config = rowan_to_ast::convert(&root, source);
(config, errors)
}
pub fn is_raw_block_directive(name: &str) -> bool {
name.ends_with("_by_lua_block")
}
const BLOCK_DIRECTIVES: &[&str] = &[
"http",
"server",
"location",
"upstream",
"events",
"stream",
"mail",
"types",
"if",
"limit_except",
"geo",
"map",
"split_clients",
"match",
];
pub fn is_block_directive(name: &str) -> bool {
BLOCK_DIRECTIVES.contains(&name) || is_raw_block_directive(name)
}
pub fn is_block_directive_with_extras(name: &str, additional: &[String]) -> bool {
is_block_directive(name) || additional.iter().any(|s| s == name)
}
pub fn is_raw_block_cst_node(block: &SyntaxNode) -> bool {
use syntax_kind::SyntaxKind;
let directive = match block.parent() {
Some(p) if p.kind() == SyntaxKind::DIRECTIVE => p,
_ => return false,
};
for child in directive.children_with_tokens() {
if let Some(t) = child.as_token()
&& t.kind() == SyntaxKind::IDENT
{
return is_raw_block_directive(t.text());
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use ast::ConfigItem;
#[test]
fn test_simple_directive() {
let config = parse_string("worker_processes auto;").unwrap();
let directives: Vec<_> = config.directives().collect();
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].name, "worker_processes");
assert_eq!(directives[0].first_arg(), Some("auto"));
}
#[test]
fn test_block_directive() {
let config = parse_string("http {\n server {\n listen 80;\n }\n}").unwrap();
let directives: Vec<_> = config.directives().collect();
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].name, "http");
assert!(directives[0].block.is_some());
let all_directives: Vec<_> = config.all_directives().collect();
assert_eq!(all_directives.len(), 3);
assert_eq!(all_directives[0].name, "http");
assert_eq!(all_directives[1].name, "server");
assert_eq!(all_directives[2].name, "listen");
}
#[test]
fn test_extension_directive() {
let config = parse_string(r#"more_set_headers "Server: Custom";"#).unwrap();
let directives: Vec<_> = config.directives().collect();
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].name, "more_set_headers");
assert_eq!(directives[0].first_arg(), Some("Server: Custom"));
}
#[test]
fn test_ssl_protocols() {
let config = parse_string("ssl_protocols TLSv1.2 TLSv1.3;").unwrap();
let directives: Vec<_> = config.directives().collect();
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].name, "ssl_protocols");
assert_eq!(directives[0].args.len(), 2);
assert_eq!(directives[0].args[0].as_str(), "TLSv1.2");
assert_eq!(directives[0].args[1].as_str(), "TLSv1.3");
}
#[test]
fn test_autoindex() {
let config = parse_string("autoindex on;").unwrap();
let directives: Vec<_> = config.directives().collect();
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].name, "autoindex");
assert!(directives[0].args[0].is_on());
}
#[test]
fn test_comment() {
let config = parse_string("# This is a comment\nworker_processes auto;").unwrap();
assert_eq!(config.items.len(), 2);
match &config.items[0] {
ConfigItem::Comment(c) => assert_eq!(c.text, "# This is a comment"),
_ => panic!("Expected comment"),
}
}
#[test]
fn test_full_config() {
let source = r#"
# Good nginx configuration
worker_processes auto;
error_log /var/log/nginx/error.log;
http {
server_tokens off;
gzip on;
server {
listen 80;
server_name example.com;
location / {
root /var/www/html;
index index.html;
}
}
}
"#;
let config = parse_string(source).unwrap();
let all_directives: Vec<_> = config.all_directives().collect();
let names: Vec<&str> = all_directives.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"worker_processes"));
assert!(names.contains(&"error_log"));
assert!(names.contains(&"server_tokens"));
assert!(names.contains(&"gzip"));
assert!(names.contains(&"listen"));
assert!(names.contains(&"server_name"));
assert!(names.contains(&"root"));
assert!(names.contains(&"index"));
}
#[test]
fn test_server_tokens_on() {
let config = parse_string("server_tokens on;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "server_tokens");
assert!(directive.first_arg_is("on"));
assert!(directive.args[0].is_on());
}
#[test]
fn test_gzip_on() {
let config = parse_string("gzip on;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "gzip");
assert!(directive.first_arg_is("on"));
}
#[test]
fn test_position_tracking() {
let config = parse_string("http {\n listen 80;\n}").unwrap();
let all_directives: Vec<_> = config.all_directives().collect();
assert_eq!(all_directives[0].span.start.line, 1);
assert_eq!(all_directives[1].span.start.line, 2);
}
#[test]
fn test_error_unmatched_brace() {
let result = parse_string("http {\n listen 80;\n");
assert!(result.is_err());
match result.unwrap_err() {
ParseError::UnclosedBlock { .. } | ParseError::UnexpectedToken { .. } => {}
e => panic!(
"Expected UnclosedBlock or UnexpectedToken error, got {:?}",
e
),
}
}
#[test]
fn test_error_missing_semicolon() {
let result = parse_string("listen 80\n}");
assert!(result.is_err());
}
#[test]
fn test_roundtrip() {
let source = "worker_processes auto;\nhttp {\n listen 80;\n}\n";
let config = parse_string(source).unwrap();
let output = config.to_source();
let reparsed = parse_string(&output).unwrap();
let names1: Vec<&str> = config.all_directives().map(|d| d.name.as_str()).collect();
let names2: Vec<&str> = reparsed.all_directives().map(|d| d.name.as_str()).collect();
assert_eq!(names1, names2);
}
#[test]
fn test_lua_directive() {
let config = parse_string("lua_code_cache on;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "lua_code_cache");
assert!(directive.first_arg_is("on"));
}
#[test]
fn test_gzip_types() {
let config = parse_string("gzip_types text/plain text/css application/json;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "gzip_types");
assert_eq!(directive.args.len(), 3);
}
#[test]
fn test_lua_block_directive() {
let config = parse_string(
r#"content_by_lua_block {
local cjson = require "cjson"
ngx.say(cjson.encode({status = "ok"}))
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "content_by_lua_block");
assert!(directive.block.is_some());
let block = directive.block.as_ref().unwrap();
assert!(block.is_raw());
assert!(block.raw_content.is_some());
let content = block.raw_content.as_ref().unwrap();
assert!(content.contains("local cjson = require"));
assert!(content.contains("ngx.say"));
}
#[test]
fn test_map_with_empty_string_key() {
let config = parse_string(
r#"map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "map");
assert!(directive.block.is_some());
let block = directive.block.as_ref().unwrap();
let directives: Vec<_> = block.directives().collect();
assert_eq!(directives.len(), 2);
assert_eq!(directives[0].name, "default");
assert_eq!(directives[1].name, ""); }
#[test]
fn test_init_by_lua_block() {
let config = parse_string(
r#"init_by_lua_block {
require "resty.core"
cjson = require "cjson"
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "init_by_lua_block");
assert!(directive.block.is_some());
let block = directive.block.as_ref().unwrap();
assert!(block.is_raw());
let content = block.raw_content.as_ref().unwrap();
assert!(content.contains("require \"resty.core\""));
}
#[test]
fn test_whitespace_capture() {
let config = parse_string("http {\n listen 80;\n}").unwrap();
let all_directives: Vec<_> = config.all_directives().collect();
assert_eq!(all_directives[0].leading_whitespace, "");
assert_eq!(all_directives[0].space_before_terminator, " ");
assert_eq!(all_directives[1].leading_whitespace, " ");
assert_eq!(all_directives[1].space_before_terminator, "");
}
#[test]
fn test_comment_whitespace_capture() {
let config = parse_string(" # test comment\nlisten 80;").unwrap();
if let ConfigItem::Comment(comment) = &config.items[0] {
assert_eq!(comment.leading_whitespace, " ");
} else {
panic!("Expected comment");
}
}
#[test]
fn test_roundtrip_preserves_whitespace() {
let source = "http {\n server {\n listen 80;\n }\n}\n";
let config = parse_string(source).unwrap();
let output = config.to_source();
let reparsed = parse_string(&output).unwrap();
let all_directives: Vec<_> = reparsed.all_directives().collect();
assert_eq!(all_directives[0].leading_whitespace, "");
assert_eq!(all_directives[1].leading_whitespace, " ");
assert_eq!(all_directives[2].leading_whitespace, " ");
}
#[test]
fn test_variable_in_argument() {
let config = parse_string("set $var value;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "set");
assert_eq!(directive.args[0].as_str(), "var");
assert!(directive.args[0].is_variable());
assert_eq!(directive.args[0].raw, "$var");
}
#[test]
fn test_variable_in_proxy_pass() {
let config = parse_string("proxy_pass http://$backend;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.args[0].as_str(), "http://");
assert!(directive.args[0].is_literal());
assert_eq!(directive.args[1].as_str(), "backend");
assert!(directive.args[1].is_variable());
}
#[test]
fn test_braced_variable() {
let config = parse_string(r#"add_header X-Request-Id "${request_id}";"#).unwrap();
let directive = config.directives().next().unwrap();
assert!(directive.args[1].is_quoted());
assert!(directive.args[1].as_str().contains("request_id"));
}
#[test]
fn test_location_exact_match() {
let config = parse_string("location = /exact { return 200; }").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "location");
assert_eq!(directive.args[0].as_str(), "=");
assert_eq!(directive.args[1].as_str(), "/exact");
}
#[test]
fn test_location_prefix_match() {
let config = parse_string("location ^~ /prefix { return 200; }").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.args[0].as_str(), "^~");
assert_eq!(directive.args[1].as_str(), "/prefix");
}
#[test]
fn test_location_regex_case_sensitive() {
let config = parse_string(r#"location ~ \.php$ { return 200; }"#).unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.args[0].as_str(), "~");
assert_eq!(directive.args[1].as_str(), r"\.php$");
}
#[test]
fn test_location_regex_case_insensitive() {
let config = parse_string(r#"location ~* \.(gif|jpg|png)$ { return 200; }"#).unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.args[0].as_str(), "~*");
assert_eq!(directive.args[1].as_str(), r"\.(gif|jpg|png)$");
}
#[test]
fn test_named_location() {
let config = parse_string("location @backend { proxy_pass http://backend; }").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.args[0].as_str(), "@backend");
}
#[test]
fn test_if_variable_check() {
let config = parse_string("if ($request_uri ~* /admin) { return 403; }").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "if");
assert!(directive.block.is_some());
}
#[test]
fn test_if_file_exists() {
let config = parse_string("if (-f $request_filename) { break; }").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "if");
assert_eq!(directive.args[0].as_str(), "(-f");
}
#[test]
fn test_upstream_basic() {
let config = parse_string(
r#"upstream backend {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "upstream");
assert_eq!(directive.args[0].as_str(), "backend");
let servers: Vec<_> = directive.block.as_ref().unwrap().directives().collect();
assert_eq!(servers.len(), 2);
}
#[test]
fn test_upstream_with_options() {
let config = parse_string(
r#"upstream backend {
server 127.0.0.1:8080 weight=5 max_fails=3 fail_timeout=30s;
keepalive 32;
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
let block = directive.block.as_ref().unwrap();
let items: Vec<_> = block.directives().collect();
assert_eq!(items[0].name, "server");
assert!(items[0].args.iter().any(|a| a.as_str().contains("weight")));
assert_eq!(items[1].name, "keepalive");
}
#[test]
fn test_geo_directive() {
let config = parse_string(
r#"geo $geo {
default unknown;
127.0.0.1 local;
10.0.0.0/8 internal;
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "geo");
assert!(directive.block.is_some());
}
#[test]
fn test_map_directive() {
let config = parse_string(
r#"map $uri $new_uri {
default $uri;
/old /new;
~^/api/v1/(.*) /api/v2/$1;
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "map");
assert_eq!(directive.args.len(), 2);
}
#[test]
fn test_single_quoted_string() {
let config = parse_string(r#"set $var 'single quoted';"#).unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.args[1].as_str(), "single quoted");
assert!(directive.args[1].is_quoted());
}
#[test]
fn test_double_quoted_string() {
let config = parse_string(r#"set $var "double quoted";"#).unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.args[1].as_str(), "double quoted");
assert!(directive.args[1].is_quoted());
}
#[test]
fn test_quoted_string_with_spaces() {
let config = parse_string(r#"add_header X-Custom "value with spaces";"#).unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.args[1].as_str(), "value with spaces");
}
#[test]
fn test_escaped_quote_in_string() {
let config = parse_string(r#"set $var "say \"hello\"";"#).unwrap();
let directive = config.directives().next().unwrap();
let value = directive.args[1].as_str();
assert!(value.contains("hello"), "value was: {}", value);
}
#[test]
fn test_include_directive() {
let config = parse_string("include /etc/nginx/conf.d/*.conf;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "include");
assert_eq!(directive.args[0].as_str(), "/etc/nginx/conf.d/*.conf");
}
#[test]
fn test_include_with_glob() {
let config = parse_string("include sites-enabled/*;").unwrap();
let directive = config.directives().next().unwrap();
assert!(directive.args[0].as_str().contains("*"));
}
#[test]
fn test_error_unexpected_closing_brace() {
let result = parse_string("listen 80; }");
assert!(result.is_err());
}
#[test]
fn test_error_unclosed_string() {
let result = parse_string(r#"set $var "unclosed;"#);
assert!(result.is_err());
}
#[test]
fn test_error_empty_directive_name() {
let result = parse_string("map $a $b { '' x; }");
assert!(result.is_ok());
}
#[test]
fn test_try_files_directive() {
let config = parse_string("try_files $uri $uri/ /index.php?$args;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "try_files");
assert!(directive.args.len() >= 3);
assert!(directive.args.iter().any(|a| a.as_str() == "uri"));
}
#[test]
fn test_rewrite_directive() {
let config = parse_string("rewrite ^/old/(.*)$ /new/$1 permanent;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "rewrite");
assert!(directive.args.len() >= 3);
assert_eq!(directive.args[0].as_str(), "^/old/(.*)$");
assert!(directive.args.iter().any(|a| a.as_str() == "permanent"));
}
#[test]
fn test_return_directive() {
let config = parse_string("return 301 https://$host$request_uri;").unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "return");
assert_eq!(directive.args[0].as_str(), "301");
}
#[test]
fn test_limit_except_block() {
let config = parse_string(
r#"location / {
limit_except GET POST {
deny all;
}
}"#,
)
.unwrap();
let all: Vec<_> = config.all_directives().collect();
assert!(all.iter().any(|d| d.name == "limit_except"));
}
#[test]
fn test_ssl_configuration() {
let config = parse_string(
r#"server {
listen 443 ssl http2;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
}"#,
)
.unwrap();
let all: Vec<_> = config.all_directives().collect();
assert!(all.iter().any(|d| d.name == "ssl_certificate"));
assert!(all.iter().any(|d| d.name == "ssl_protocols"));
}
#[test]
fn test_proxy_configuration() {
let config = parse_string(
r#"location /api {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
}"#,
)
.unwrap();
let all: Vec<_> = config.all_directives().collect();
let proxy_headers: Vec<_> = all
.iter()
.filter(|d| d.name == "proxy_set_header")
.collect();
assert_eq!(proxy_headers.len(), 3);
}
#[test]
fn test_deeply_nested_blocks() {
let config = parse_string(
r#"http {
server {
location / {
if ($request_method = POST) {
return 405;
}
}
}
}"#,
)
.unwrap();
let all: Vec<_> = config.all_directives().collect();
assert_eq!(all.len(), 5); }
#[test]
fn test_argument_is_on_off() {
let config = parse_string("gzip on; gzip_static off;").unwrap();
let directives: Vec<_> = config.directives().collect();
assert!(directives[0].args[0].is_on());
assert!(!directives[0].args[0].is_off());
assert!(directives[1].args[0].is_off());
assert!(!directives[1].args[0].is_on());
}
#[test]
fn test_argument_is_literal() {
let config = parse_string(r#"set $var "quoted"; set $var2 literal;"#).unwrap();
let directives: Vec<_> = config.directives().collect();
assert!(!directives[0].args[1].is_literal());
assert!(directives[1].args[1].is_literal());
}
#[test]
fn test_blank_lines_preserved() {
let config =
parse_string("worker_processes 1;\n\nerror_log /var/log/error.log;\n").unwrap();
assert_eq!(config.items.len(), 3);
assert!(matches!(config.items[1], ConfigItem::BlankLine(_)));
}
#[test]
fn test_multiple_blank_lines() {
let config = parse_string("a 1;\n\n\nb 2;\n").unwrap();
let blank_count = config
.items
.iter()
.filter(|i| matches!(i, ConfigItem::BlankLine(_)))
.count();
assert_eq!(blank_count, 2);
}
#[test]
fn test_events_block() {
let config = parse_string(
r#"events {
worker_connections 1024;
use epoll;
multi_accept on;
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "events");
let inner: Vec<_> = directive.block.as_ref().unwrap().directives().collect();
assert_eq!(inner.len(), 3);
}
#[test]
fn test_stream_block() {
let config = parse_string(
r#"stream {
server {
listen 12345;
proxy_pass backend;
}
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "stream");
}
#[test]
fn test_types_block() {
let config = parse_string(
r#"types {
text/html html htm;
text/css css;
application/javascript js;
}"#,
)
.unwrap();
let directive = config.directives().next().unwrap();
assert_eq!(directive.name, "types");
let inner: Vec<_> = directive.block.as_ref().unwrap().directives().collect();
assert_eq!(inner.len(), 3);
assert_eq!(inner[0].name, "text/html");
}
#[test]
fn test_utf8_comment_column_tracking() {
let config = parse_string("# 開発環境\nlisten 80;").unwrap();
if let ast::ConfigItem::Comment(c) = &config.items[0] {
assert_eq!(c.span.start.line, 1);
assert_eq!(c.span.start.column, 1);
assert_eq!(c.span.end.column, 15);
} else {
panic!("expected Comment");
}
let directives: Vec<_> = config.all_directives().collect();
assert_eq!(directives[0].span.start.line, 2);
assert_eq!(directives[0].span.start.column, 1);
}
#[test]
fn test_utf8_comment_byte_offset_tracking() {
let config = parse_string("# 開発環境\nlisten 80;").unwrap();
if let ast::ConfigItem::Comment(c) = &config.items[0] {
assert_eq!(c.span.start.offset, 0);
assert_eq!(c.span.end.offset, 14);
} else {
panic!("expected Comment");
}
let directives: Vec<_> = config.all_directives().collect();
assert_eq!(directives[0].span.start.offset, 15);
}
}