refactor(rules): simplify nested block detection by removing @ prefix requirement

Changes the nested block syntax detection from requiring `@`
as the first non-space character on a line to using a line-ending brace heuristic.

The parser now recognizes nested blocks when a line ends with an unquoted `{`,
simplifying the syntax and removing the mandatory `@` prefix while maintaining the same functionality.
This commit is contained in:
yusing
2026-02-24 01:30:32 +08:00
parent 5ba475c489
commit 5b20bbeb6f
5 changed files with 131 additions and 26 deletions

View File

@@ -11,22 +11,22 @@ default {
header X-Test-Header {
set header X-Remote-Type public
@remote 127.0.0.1 | remote 192.168.0.0/16 {
remote 127.0.0.1 | remote 192.168.0.0/16 {
set header X-Remote-Type private
}
}
path glob(/api/admin/*) {
@cookie session-id {
cookie session-id {
set header X-Session-ID $cookie(session-id)
}
}
!remote 192.168.0.0/16 {
@!header X-User-Role admin & !header X-User-Role user {
!header X-User-Role admin & !header X-User-Role user {
error 403 "Access denied"
} elif remote 127.0.0.1 {
@header X-User-Role staff {
header X-User-Role staff {
set header X-User-Role staff
}
} else {

View File

@@ -176,7 +176,7 @@ func TestParseBlockRules_NestedBlocks(t *testing.T) {
rules := testParseRules(t, `
header X-Test-Header {
set header X-Remote-Type public
@remote 127.0.0.1 | remote 192.168.0.0/16 {
remote 127.0.0.1 | remote 192.168.0.0/16 {
set header X-Remote-Type private
}
}`)
@@ -226,7 +226,7 @@ func TestParseBlockRules_NestedBlocks_ElifElse(t *testing.T) {
rules := testParseRules(t, `
header X-Test-Header {
set header X-Mode outer
@method GET {
method GET {
set header X-Mode get
} elif method POST {
set header X-Mode post
@@ -289,7 +289,7 @@ default {
func TestParseBlockRules_NestedBlocks_ElifMustBeSameLine(t *testing.T) {
err := testParseRulesError(t, `header X-Test-Header {
@method GET {
method GET {
set header X-Mode get
}
elif method POST {
@@ -301,7 +301,7 @@ func TestParseBlockRules_NestedBlocks_ElifMustBeSameLine(t *testing.T) {
func TestParseBlockRules_NestedBlocks_ElseMustBeLastOnLine(t *testing.T) {
err := testParseRulesError(t, `header X-Test-Header {
@method GET {
method GET {
set header X-Mode get
} else {
set header X-Mode other
@@ -313,7 +313,7 @@ func TestParseBlockRules_NestedBlocks_ElseMustBeLastOnLine(t *testing.T) {
func TestParseBlockRules_NestedBlocks_MultipleElse(t *testing.T) {
err := testParseRulesError(t, `header X-Test-Header {
@method GET {
method GET {
set header X-Mode get
} else {
set header X-Mode other
@@ -328,7 +328,7 @@ func TestParseBlockRules_NestedBlocks_MultipleElse(t *testing.T) {
func TestParseBlockRules_NestedBlocks_ElifMissingOnExpr(t *testing.T) {
err := testParseRulesError(t, `header X-Test-Header {
@method GET {
method GET {
set header X-Mode get
} elif {
set header X-Mode post
@@ -337,3 +337,54 @@ func TestParseBlockRules_NestedBlocks_ElifMissingOnExpr(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "expected on-expr after 'elif'")
}
func TestParseBlockRules_NestedBlocks_LineEndingBraceHeuristic(t *testing.T) {
rules := testParseRules(t, `{
set header X-Literal "{"
}`)
require.Len(t, rules, 1)
require.Len(t, rules[0].Do.pre, 1)
_, ok := rules[0].Do.pre[0].(Handler)
require.True(t, ok)
}
func TestParseBlockRules_NestedBlocks_LineEndingBraceWithTrailingSpaces(t *testing.T) {
rules := testParseRules(t, `header X-Test-Header {
method GET {
set header X-Mode get
}
}`)
require.Len(t, rules, 1)
require.Len(t, rules[0].Do.pre, 1)
ifCmd, ok := rules[0].Do.pre[0].(IfBlockCommand)
require.True(t, ok)
assert.Equal(t, "method GET", ifCmd.On.raw)
}
func TestParseBlockRules_NestedBlocks_LineEndingBraceWithTrailingComment(t *testing.T) {
rules := testParseRules(t, `header X-Test-Header {
method GET { // GET branch
set header X-Mode get
} else { # fallback branch
set header X-Mode other
}
}`)
require.Len(t, rules, 1)
require.Len(t, rules[0].Do.pre, 1)
ifCmd, ok := rules[0].Do.pre[0].(IfElseBlockCommand)
require.True(t, ok)
require.Len(t, ifCmd.Ifs, 1)
assert.Equal(t, "method GET", ifCmd.Ifs[0].On.raw)
require.NotNil(t, ifCmd.Else)
}
func TestParseBlockRules_NestedBlocks_LineEndingBraceInterpretsAsBlock(t *testing.T) {
err := testParseRulesError(t, `{
set header X-Bad {
set header X-Test fail
}
}`)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid `rule.on` target")
}

View File

@@ -13,7 +13,7 @@ import (
//
// Syntax (within a rule do block):
//
// @<on-expr> { <do...> }
// <on-expr> { <do...> }
//
// Semantics:
// - Evaluated in the same phase the parent rule runs.
@@ -53,7 +53,7 @@ func (c IfBlockCommand) Phase() PhaseFlag {
//
// Syntax (within a rule do block):
//
// @<on-expr> { <do...> } elif <on-expr> { <do...> } ... else { <do...> }
// <on-expr> { <do...> } elif <on-expr> { <do...> } ... else { <do...> }
//
// NOTE: `elif`/`else` must appear on the same line as the preceding closing brace (`}`),
// e.g. `} elif ... {` and `} else {`.
@@ -113,9 +113,9 @@ func skipSameLineSpace(src string, pos int) int {
return pos
}
func parseAtBlockChain(src string, atPos int) (CommandHandler, int, error) {
func parseAtBlockChain(src string, blockPos int) (CommandHandler, int, error) {
length := len(src)
headerStart := atPos + 1
headerStart := blockPos
parseBranch := func(onExpr string, bodyStart int, bodyEnd int) (RuleOn, []CommandHandler, error) {
var on RuleOn
@@ -140,11 +140,14 @@ func parseAtBlockChain(src string, atPos int) (CommandHandler, int, error) {
if herr != nil {
return nil, 0, herr
}
if onExpr == "" {
return nil, 0, ErrInvalidBlockSyntax.Withf("expected on-expr before '{'")
}
if bracePos >= length || src[bracePos] != '{' {
return nil, 0, ErrInvalidBlockSyntax.Withf("expected '{' after nested block header")
}
// Parse first @<on-expr> { ... }
// Parse first <on-expr> { ... }
p := bracePos
bodyStart := p + 1
bodyEnd, ferr := findMatchingBrace(src, &p, bodyStart)
@@ -288,10 +291,56 @@ func parseAtBlockChain(src string, atPos int) (CommandHandler, int, error) {
return IfBlockCommand{On: ifs[0].On, Do: ifs[0].Do}, p, nil
}
// parseDoWithBlocks parses a do-body containing plain command lines and nested @-blocks.
func lineEndsWithUnquotedOpenBrace(src string, lineStart int, lineEnd int) bool {
quote := byte(0)
lastSignificant := byte(0)
atLineStart := true
prevIsSpace := true
for i := lineStart; i < lineEnd; i++ {
c := src[i]
if quote != 0 {
if c == '\\' && i+1 < lineEnd {
i++
continue
}
if c == quote {
quote = 0
}
atLineStart = false
prevIsSpace = false
continue
}
if quoteChars[c] {
quote = c
atLineStart = false
prevIsSpace = false
continue
}
if c == '#' && (atLineStart || prevIsSpace) {
break
}
if c == '/' && i+1 < lineEnd {
n := rune(src[i+1])
if (atLineStart || prevIsSpace) && (n == '/' || n == '*') {
break
}
}
if unicode.IsSpace(rune(c)) {
prevIsSpace = true
continue
}
lastSignificant = c
atLineStart = false
prevIsSpace = false
}
return quote == 0 && lastSignificant == '{'
}
// parseDoWithBlocks parses a do-body containing plain command lines and nested blocks.
// It returns the outer command handlers and the require phase.
//
// A nested block is only recognized when '@' is the first non-space character on a line.
// A nested block is recognized when a line ends with an unquoted '{' (ignoring trailing whitespace).
func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) {
pos := 0
length := len(src)
@@ -351,7 +400,12 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) {
linePos++
}
if linePos < length && src[linePos] == '@' {
lineEnd := linePos
for lineEnd < length && src[lineEnd] != '\n' {
lineEnd++
}
if linePos < length && lineEndsWithUnquotedOpenBrace(src, linePos, lineEnd) {
h, next, err := parseAtBlockChain(src, linePos)
if err != nil {
return nil, err
@@ -363,7 +417,7 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) {
}
// Not a nested block; parse the rest of this line as a command.
lineEnd := pos
lineEnd = pos
for lineEnd < length && src[lineEnd] != '\n' {
lineEnd++
}

View File

@@ -457,7 +457,7 @@ func TestHTTPFlow_NestedBlocks_RemoteOverride(t *testing.T) {
err := parseRules(`
header X-Test-Header {
set header X-Remote-Type public
@remote 127.0.0.1 | remote 192.168.0.0/16 {
remote 127.0.0.1 | remote 192.168.0.0/16 {
set header X-Remote-Type private
}
}
@@ -495,7 +495,7 @@ func TestHTTPFlow_NestedBlocks_ElifElse(t *testing.T) {
var rules Rules
err := parseRules(`
header X-Test-Header {
@method GET {
method GET {
set header X-Mode get
} elif method POST {
set header X-Mode post
@@ -552,7 +552,7 @@ func TestHTTPFlow_NestedBlocks_TerminatingActionStopsFlow(t *testing.T) {
err := parseRules(`
path / {
set header X-Pre pre
@header X-Block {
header X-Block {
error 403 "blocked"
}
set resp_header X-After should-not-run
@@ -593,7 +593,7 @@ func TestHTTPFlow_NestedBlocks_InResponseRule_ModifiesResponseByRequestMethod(t
err := parseRules(`
{
set header X-Method "should-be-overridden"
@method POST {
method POST {
set header X-Method "post"
} elif method GET {
set header X-Method "get"

View File

@@ -79,7 +79,7 @@ header Host example.com {
name: "same condition with terminating handler inside if block",
rules: `
header Host example.com {
@default {
default {
error 404 "not found"
}
}
@@ -94,7 +94,7 @@ header Host example.com {
name: "same condition with terminating handler across if else block",
rules: `
header Host example.com {
@method GET {
method GET {
error 404 "not found"
} else {
redirect https://example.com
@@ -111,7 +111,7 @@ header Host example.com {
name: "same condition with non terminating if branch in if else block",
rules: `
header Host example.com {
@method GET {
method GET {
set resp_header X-Test first
} else {
error 404 "not found"