mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-19 15:31:37 +02:00
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
This commit is contained in:
406
internal/route/rules/block_parser.go
Normal file
406
internal/route/rules/block_parser.go
Normal file
@@ -0,0 +1,406 @@
|
||||
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 {
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user