Files
godoxy-yusing/internal/route/rules/block_parser.go
Yuzerion d2d686b4d1 feat(rules): introduce block DSL, phase-based execution (#203)
* chore(deps): update submodule goutils

* docs(http): remove default client from README.md

* refactor(rules): introduce block DSL, phase-based execution, and flow validation

- add block syntax parser/scanner with nested @blocks and elif/else support
- restructure rule execution into explicit pre/post phases with phase flags
- classify commands by phase and termination behavior
- enforce flow semantics (default rule handling, dead-rule detection)
- expand HTTP flow coverage with block + YAML parity tests and benches
- refresh rules README/spec and update playground/docs integration
- Default rules act as fallback handlers that execute only when no matching non-default rule exists in the pre phase
- IfElseBlockCommand now returns early when a condition matches with a nil Do block, instead of falling through to else blocks
- Add nil check for auth handler to allow requests when no auth is configured

* fix(rules): buffer log output before writing to stdout/stderr

* refactor(api/rules): remove IsResponseRule field from ParsedRule and related logic

* docs(rules): update examples to use block syntax
2026-02-24 10:44:47 +08:00

410 lines
9.0 KiB
Go

package rules
import (
"strings"
"unicode"
"github.com/yusing/goutils/env"
gperr "github.com/yusing/goutils/errs"
)
func getStringBuffer(size int) *strings.Builder {
var buf strings.Builder
if size > 0 {
buf.Grow(size)
}
return &buf
}
// expandEnvVarsRaw expands ${NAME} in-place using env.LookupEnv (prefix-aware).
func expandEnvVarsRaw(v string) (string, gperr.Error) {
buf := getStringBuffer(len(v))
envVar := getStringBuffer(0)
var missingEnvVars []string
inEnvVar := false
expectingBrace := false
for _, r := range v {
if expectingBrace && r != '{' && r != '$' {
buf.WriteRune('$')
expectingBrace = false
}
switch r {
case '$':
if expectingBrace {
buf.WriteRune('$')
expectingBrace = false
} else {
expectingBrace = true
}
case '{':
if expectingBrace {
inEnvVar = true
expectingBrace = false
envVar.Reset()
} else {
buf.WriteRune(r)
}
case '}':
if inEnvVar {
envValue, ok := env.LookupEnv(envVar.String())
if !ok {
missingEnvVars = append(missingEnvVars, envVar.String())
} else {
buf.WriteString(envValue)
}
inEnvVar = false
} else {
buf.WriteRune(r)
}
default:
if expectingBrace {
buf.WriteRune('$')
expectingBrace = false
}
if inEnvVar {
envVar.WriteRune(r)
} else {
buf.WriteRune(r)
}
}
}
if expectingBrace {
buf.WriteRune('$')
}
var err gperr.Error
if inEnvVar {
// Write back the unterminated ${...} so the output matches the input.
buf.WriteString("${")
buf.WriteString(envVar.String())
err = ErrUnterminatedEnvVar
}
if len(missingEnvVars) > 0 {
err = gperr.Join(err, ErrEnvVarNotFound.With(gperr.Multiline().AddStrings(missingEnvVars...)))
}
return buf.String(), err
}
// parseBlockRules parses the block-syntax rule format.
// Grammar:
//
// file := { ws | comment | rule }
// rule := default_rule | conditional_rule
// default_rule := 'default' ws* block
// conditional_rule := on_expr ws* block
// block := '{' do_body '}'
//
// Where:
// - on_expr is passed verbatim to RuleOn.Parse()
// - do_body is passed verbatim to Command.Parse()
//
// Comments (ignored outside quotes/backticks):
// - line comment: // ... or # ...
// - block comment: /* ... */
//
// Brace handling:
// - Braces inside quotes/backticks are ignored
// - Braces inside ${...} (env vars) are ignored in do_body
// - Braces in on_expr are not ignored (env vars must be quoted in on_expr)
//
//nolint:dupword
func parseBlockRules(src string) (Rules, gperr.Error) {
var rules Rules
var errs gperr.Builder
pos := 0
length := len(src)
t := newTokenizer(src)
for pos < length {
// Skip whitespace/comments between rules.
newPos, skipErr := t.skipComments(pos, true, true)
if skipErr != nil {
return nil, ErrInvalidBlockSyntax.Withf("at position %d", pos)
}
pos = newPos
if pos >= length {
break
}
// Stray closing brace at top-level: keep parsing but mark invalid so Rules.Validate() fails.
if src[pos] == '}' {
return nil, ErrInvalidBlockSyntax.Withf("unmatched '}' at position %d", pos)
}
// Parse rule header (default, unconditional, or on_expr)
headerStart := pos
header := parseRuleHeader(&t, src, &pos, length)
headerStr := src[headerStart:pos]
// Skip whitespace/comments before '{' (default header may end before '{').
newPos, skipErr = t.skipComments(pos, false, true)
if skipErr != nil {
return nil, ErrInvalidBlockSyntax.Withf("at position %d", pos)
}
pos = newPos
if pos >= length || src[pos] != '{' {
errs.AddSubjectf(ErrInvalidBlockSyntax, "expected '{' after rule header %q", headerStr)
return nil, errs.Error()
}
// Find matching '}' (respecting quotes and env vars in do_body)
bodyStart := pos + 1
bodyEnd, err := t.findMatchingBrace(bodyStart)
if err != nil {
errs.AddSubjectf(err, "rule header %q", headerStr)
return nil, errs.Error()
}
pos = bodyEnd + 1
onExpr := header
doBody := ""
if bodyStart < bodyEnd {
doBody = src[bodyStart:bodyEnd]
}
// Normalize do body for the inner DSL parser:
// - strip comments (outside quotes/backticks)
// - trim block whitespace/indentation
// - expand ${ENV} in-place so cmd.raw is usable/debuggable
doBody, err = preprocessDoBody(doBody)
if err != nil {
errs.AddSubjectf(err, "rule header %q", headerStr)
return nil, errs.Error()
}
rule := Rule{
Name: "", // auto-generate if empty
On: RuleOn{},
Do: Command{},
}
// Header semantics:
// - "default" => default rule (matched when no other rules are matched)
// - "" => unconditional rule (always matches)
// - otherwise => conditional rule (on expression)
switch onExpr {
case "default":
rule.On.raw = OnDefault
case "":
// leave rule.On as zero value => checker=nil => always matches
default:
if parseErr := rule.On.Parse(onExpr); parseErr != nil {
errs.AddSubjectf(parseErr, "on")
}
}
if doBody != "" {
if parseErr := rule.Do.Parse(doBody); parseErr != nil {
errs.AddSubjectf(parseErr, "do")
}
}
if errs.HasError() {
return nil, errs.Error()
}
rules = append(rules, rule)
}
return rules, nil
}
func preprocessDoBody(doBody string) (string, gperr.Error) {
doBody = strings.TrimSpace(doBody)
if doBody == "" {
return "", nil
}
normalized := doBody
// If comments are possible, strip them first while preserving line breaks.
if strings.ContainsAny(normalized, "#/") {
stripped, err := stripCommentsPreserveNewlines(normalized)
if err != nil {
return "", err
}
normalized = stripped
}
// Drop lines that are empty after trimming, while preserving indentation of non-empty lines.
out := getStringBuffer(len(normalized))
lineStart := 0
wroteLine := false
for i := 0; i <= len(normalized); i++ {
if i < len(normalized) && normalized[i] != '\n' {
continue
}
line := normalized[lineStart:i]
if strings.TrimSpace(line) != "" {
if wroteLine {
out.WriteByte('\n')
}
out.WriteString(line)
wroteLine = true
}
lineStart = i + 1
}
if !wroteLine {
return "", nil
}
normalized = out.String()
// Expand env vars to keep Command.raw consistent with parsed semantics.
if !strings.Contains(normalized, "${") {
return normalized, nil
}
expanded, err := expandEnvVarsRaw(normalized)
if err != nil {
return "", err
}
return expanded, nil
}
// stripCommentsPreserveNewlines removes //, #, and /* */ comments outside quotes/backticks.
// It preserves newlines so command line boundaries remain intact.
func stripCommentsPreserveNewlines(src string) (string, gperr.Error) {
if !strings.ContainsAny(src, "#/") {
return src, nil
}
out := getStringBuffer(len(src))
quote := rune(0)
inLine := false
inBlock := false
atLineStart := true
prevIsSpace := true
for i := 0; i < len(src); {
c := src[i]
if inLine {
if c == '\n' {
inLine = false
out.WriteByte('\n')
atLineStart = true
prevIsSpace = true
}
i++
continue
}
if inBlock {
if c == '\n' {
out.WriteByte('\n')
atLineStart = true
prevIsSpace = true
i++
continue
}
if c == '*' && i+1 < len(src) && src[i+1] == '/' {
inBlock = false
i += 2
continue
}
i++
continue
}
if quote != 0 {
out.WriteByte(c)
if c == '\\' && i+1 < len(src) {
// Write next char and skip it (escape sequence)
i++
out.WriteByte(src[i])
atLineStart = false
prevIsSpace = false
i++
continue
}
if rune(c) == quote {
quote = 0
}
if c == '\n' {
atLineStart = true
prevIsSpace = true
} else {
atLineStart = false
prevIsSpace = unicode.IsSpace(rune(c))
}
i++
continue
}
// Not in quote/comment.
switch c {
case '\'', '"', '`':
quote = rune(c)
out.WriteByte(c)
atLineStart = false
prevIsSpace = false
i++
continue
case '#':
if atLineStart || prevIsSpace {
inLine = true
i++
continue
}
case '/':
if i+1 < len(src) {
n := src[i+1]
if (atLineStart || prevIsSpace) && n == '/' {
inLine = true
i += 2
continue
}
if (atLineStart || prevIsSpace) && n == '*' {
inBlock = true
i += 2
continue
}
}
}
out.WriteByte(c)
if c == '\n' {
atLineStart = true
prevIsSpace = true
} else {
atLineStart = false
prevIsSpace = unicode.IsSpace(rune(c))
}
i++
}
if inBlock {
return "", ErrInvalidBlockSyntax.Withf("unterminated block comment")
}
return out.String(), nil
}
// parseRuleHeader parses the rule header (default or on expression).
// Returns the header string, or "" if parsing failed.
func parseRuleHeader(t *Tokenizer, src string, pos *int, length int) string {
start := *pos
// Check for 'default' keyword
if *pos+7 <= length && src[*pos:*pos+7] == "default" {
next := *pos + 7
if next >= length || unicode.IsSpace(rune(src[next])) {
*pos = next
return "default"
}
}
// Parse on expression until we hit '{' outside quotes.
bracePos, err := t.scanToBrace(*pos)
if err != nil {
*pos = length
return strings.TrimSpace(src[start:*pos])
}
*pos = bracePos
return strings.TrimSpace(src[start:*pos])
}