新增: u-tpl SQL 模板引擎完整实现

- Lexer/Parser/Executor 三阶段架构
- #{param} 参数化 + ${raw} 原样替换 + 白名单安全策略
- @if/@for/@tpl/@include/@namespace 控制流
- 表达式引擎: 比较、逻辑、nil 检查、len() 内置函数
- 支持 ?/$1/:1 多数据库占位符风格
- 零依赖,纯 Go 标准库实现
This commit is contained in:
2026-04-01 00:27:50 +08:00
parent 71d7f6590a
commit 861d58d718
21 changed files with 4125 additions and 3 deletions

30
internal/builtin.go Normal file
View File

@@ -0,0 +1,30 @@
package internal
import "reflect"
type BuiltinFunc func(args []any) (any, bool)
var builtins = map[string]BuiltinFunc{
"len": builtinLen,
}
func builtinLen(args []any) (any, bool) {
if len(args) != 1 {
return nil, false
}
if args[0] == nil {
return 0, true
}
v := reflect.ValueOf(args[0])
switch v.Kind() {
case reflect.Slice, reflect.Array, reflect.String, reflect.Map, reflect.Chan:
return v.Len(), true
default:
return 0, false
}
}
func LookupBuiltin(name string) (BuiltinFunc, bool) {
fn, ok := builtins[name]
return fn, ok
}

68
internal/context.go Normal file
View File

@@ -0,0 +1,68 @@
package internal
import (
"reflect"
"strings"
)
type Context struct {
vars map[string]any
}
func NewContext(vars map[string]any) *Context {
return &Context{vars: vars}
}
func (c *Context) Get(path string) (any, bool) {
if c.vars == nil {
return nil, false
}
return resolvePath(c.vars, path)
}
func resolvePath(current any, path string) (any, bool) {
for seg := range strings.SplitSeq(path, ".") {
if current == nil {
return nil, false
}
switch v := current.(type) {
case map[string]any:
var ok bool
current, ok = v[seg]
if !ok {
return nil, false
}
default:
val := reflect.ValueOf(current)
for val.Kind() == reflect.Pointer {
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return nil, false
}
field := val.FieldByName(seg)
if !field.IsValid() {
field = findFieldIgnoreCase(val, seg)
}
if !field.IsValid() {
return nil, false
}
current = field.Interface()
}
}
return current, true
}
func findFieldIgnoreCase(v reflect.Value, name string) reflect.Value {
typ := v.Type()
for i := range typ.NumField() {
f := typ.Field(i)
if !f.IsExported() {
continue
}
if strings.EqualFold(f.Name, name) {
return v.Field(i)
}
}
return reflect.Value{}
}

167
internal/executor.go Normal file
View File

@@ -0,0 +1,167 @@
package internal
import (
"fmt"
"maps"
"reflect"
"strings"
)
type rawValidator interface {
Validate(param string, value string) error
}
type Executor struct {
style PlaceholderStyle
rawPolicy rawValidator
strict bool
}
type Result struct {
SQL string
Args []any
}
func NewExecutor(style PlaceholderStyle, rawPolicy rawValidator, strict bool) *Executor {
return &Executor{
style: style,
rawPolicy: rawPolicy,
strict: strict,
}
}
func (e *Executor) Execute(nodes []Node, vars map[string]any) (*Result, error) {
ctx := NewContext(vars)
ph := NewPlaceholder(e.style)
var sql strings.Builder
var args []any
err := e.walk(ctx, ph, &sql, &args, nodes)
if err != nil {
return nil, err
}
s := strings.TrimRight(sql.String(), " \t\n\r")
if len(s) > 0 && s[len(s)-1] == ',' {
s = s[:len(s)-1]
}
return &Result{SQL: s, Args: args}, nil
}
func (e *Executor) walk(ctx *Context, ph *Placeholder, sql *strings.Builder, args *[]any, nodes []Node) error {
for _, node := range nodes {
switch n := node.(type) {
case *TextNode:
sql.WriteString(n.Text)
case *ParamNode:
val, ok := ctx.Get(n.Name)
if !ok {
if e.strict {
return fmt.Errorf("line %d, col %d: undefined variable %q", n.Pos.Line, n.Pos.Col, n.Name)
}
val = nil
}
sql.WriteString(ph.Next())
*args = append(*args, val)
case *RawNode:
val, ok := ctx.Get(n.Name)
if !ok {
if e.strict {
return fmt.Errorf("line %d, col %d: undefined variable %q", n.Pos.Line, n.Pos.Col, n.Name)
}
val = ""
}
strVal, ok := val.(string)
if !ok {
strVal = fmt.Sprint(val)
}
if e.rawPolicy != nil {
if err := e.rawPolicy.Validate(n.Name, strVal); err != nil {
return err
}
}
sql.WriteString(strVal)
case *IfNode:
err := e.walkIf(ctx, ph, sql, args, n)
if err != nil {
return err
}
case *ForNode:
err := e.walkFor(ctx, ph, sql, args, n)
if err != nil {
return err
}
case *BlockNode, *NamespaceNode, *IncludeNode, *CommentNode:
// skip
}
}
return nil
}
func (e *Executor) walkIf(ctx *Context, ph *Placeholder, sql *strings.Builder, args *[]any, n *IfNode) error {
condVal, err := Eval(n.Cond, ctx)
if err != nil {
return err
}
if isTruthy(condVal) {
return e.walk(ctx, ph, sql, args, n.Body)
}
for _, branch := range n.ElseIf {
condVal, err = Eval(branch.Cond, ctx)
if err != nil {
return err
}
if isTruthy(condVal) {
return e.walk(ctx, ph, sql, args, branch.Body)
}
}
if len(n.Else) > 0 {
return e.walk(ctx, ph, sql, args, n.Else)
}
return nil
}
func (e *Executor) walkFor(ctx *Context, ph *Placeholder, sql *strings.Builder, args *[]any, n *ForNode) error {
listVal, err := Eval(n.List, ctx)
if err != nil {
return err
}
if listVal == nil {
return nil
}
rv := reflect.ValueOf(listVal)
for rv.Kind() == reflect.Pointer {
rv = rv.Elem()
}
var length int
switch rv.Kind() {
case reflect.Slice, reflect.Array:
length = rv.Len()
default:
return fmt.Errorf("line %d, col %d: @for requires a slice or array, got %T", n.Pos.Line, n.Pos.Col, listVal)
}
for i := 0; i < length; i++ {
childVars := make(map[string]any, len(ctx.vars)+2)
maps.Copy(childVars, ctx.vars)
if n.KeyVar != "" {
childVars[n.KeyVar] = i
}
childVars[n.ValVar] = rv.Index(i).Interface()
childCtx := NewContext(childVars)
err := e.walk(childCtx, ph, sql, args, n.Body)
if err != nil {
return err
}
}
return nil
}

564
internal/expr.go Normal file
View File

@@ -0,0 +1,564 @@
package internal
import (
"fmt"
"reflect"
"strconv"
"strings"
"unicode"
)
type ExprParser struct {
input []rune
pos int
line int
col int
}
func NewExprParser(input string, line, col int) *ExprParser {
return &ExprParser{
input: []rune(input),
pos: 0,
line: line,
col: col,
}
}
func (p *ExprParser) Parse() (*Expr, error) {
expr, err := p.parseOr()
if err != nil {
return nil, err
}
return expr, nil
}
func (p *ExprParser) parseOr() (*Expr, error) {
left, err := p.parseAnd()
if err != nil {
return nil, err
}
for {
p.skipSpaces()
if !p.peekStr("||") {
break
}
p.skip(2)
p.skipSpaces()
right, err := p.parseAnd()
if err != nil {
return nil, err
}
left = &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprBinary, Left: left, Op: "||", Right: right}
}
return left, nil
}
func (p *ExprParser) parseAnd() (*Expr, error) {
left, err := p.parseCompare()
if err != nil {
return nil, err
}
for {
p.skipSpaces()
if !p.peekStr("&&") {
break
}
p.skip(2)
p.skipSpaces()
right, err := p.parseCompare()
if err != nil {
return nil, err
}
left = &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprBinary, Left: left, Op: "&&", Right: right}
}
return left, nil
}
func (p *ExprParser) parseCompare() (*Expr, error) {
left, err := p.parseUnary()
if err != nil {
return nil, err
}
p.skipSpaces()
op := ""
if p.peekStr("==") {
op = "=="
p.skip(2)
} else if p.peekStr("!=") {
op = "!="
p.skip(2)
} else if p.peekStr("<=") {
op = "<="
p.skip(2)
} else if p.peekStr(">=") {
op = ">="
p.skip(2)
} else if p.peekStr("<") {
op = "<"
p.skip(1)
} else if p.peekStr(">") {
op = ">"
p.skip(1)
}
if op == "" {
return left, nil
}
p.skipSpaces()
right, err := p.parseUnary()
if err != nil {
return nil, err
}
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprBinary, Left: left, Op: op, Right: right}, nil
}
func (p *ExprParser) parseUnary() (*Expr, error) {
p.skipSpaces()
if p.peekStr("!") && !p.peekStr("!=") {
p.skip(1)
operand, err := p.parseUnary()
if err != nil {
return nil, err
}
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprUnary, UnaryOp: "!", Operand: operand}, nil
}
return p.parsePrimary()
}
func (p *ExprParser) parsePrimary() (*Expr, error) {
p.skipSpaces()
if p.pos >= len(p.input) {
return nil, fmt.Errorf("line %d, col %d: unexpected end of expression", p.line, p.col)
}
ch := p.input[p.pos]
if ch == '"' {
return p.parseStringLit()
}
if ch == '\'' {
return p.parseSingleQuoteStringLit()
}
if ch >= '0' && ch <= '9' {
return p.parseNumberLit()
}
if isIdentStart(ch) {
return p.parseIdentOrKeyword()
}
if ch == '(' {
p.skip(1)
p.skipSpaces()
expr, err := p.parseOr()
if err != nil {
return nil, err
}
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
return nil, fmt.Errorf("line %d, col %d: expected ')'", p.line, p.col)
}
p.skip(1)
return expr, nil
}
return nil, fmt.Errorf("line %d, col %d: unexpected character %q", p.line, p.col, string(ch))
}
func (p *ExprParser) parseIdentOrKeyword() (*Expr, error) {
start := p.pos
for p.pos < len(p.input) && isIdentPart(p.input[p.pos]) {
p.pos++
}
name := string(p.input[start:p.pos])
switch name {
case "true":
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprLiteral, Value: true}, nil
case "false":
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprLiteral, Value: false}, nil
case "nil":
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprNil}, nil
}
p.skipSpaces()
if p.pos < len(p.input) && p.input[p.pos] == '(' {
return p.parseFuncCall(name)
}
p.skipSpaces()
varName := name
for p.pos < len(p.input) && p.input[p.pos] == '.' {
p.skip(1)
if p.pos >= len(p.input) || !isIdentStart(p.input[p.pos]) {
return nil, fmt.Errorf("line %d, col %d: expected identifier after '.'", p.line, p.col)
}
segStart := p.pos
for p.pos < len(p.input) && isIdentPart(p.input[p.pos]) {
p.pos++
}
varName += "." + string(p.input[segStart:p.pos])
p.skipSpaces()
}
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprVariable, Name: varName}, nil
}
func (p *ExprParser) parseFuncCall(name string) (*Expr, error) {
p.skip(1)
var args []*Expr
p.skipSpaces()
if p.pos < len(p.input) && p.input[p.pos] != ')' {
for {
arg, err := p.parseOr()
if err != nil {
return nil, err
}
args = append(args, arg)
p.skipSpaces()
if p.pos < len(p.input) && p.input[p.pos] == ',' {
p.skip(1)
p.skipSpaces()
continue
}
break
}
}
p.skipSpaces()
if p.pos >= len(p.input) || p.input[p.pos] != ')' {
return nil, fmt.Errorf("line %d, col %d: expected ')' after function call", p.line, p.col)
}
p.skip(1)
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprFuncCall, FuncName: name, FuncArgs: args}, nil
}
func (p *ExprParser) parseStringLit() (*Expr, error) {
p.skip(1)
var buf strings.Builder
for p.pos < len(p.input) && p.input[p.pos] != '"' {
if p.input[p.pos] == '\\' && p.pos+1 < len(p.input) {
p.pos++
switch p.input[p.pos] {
case 'n':
buf.WriteRune('\n')
case 't':
buf.WriteRune('\t')
case '\\':
buf.WriteRune('\\')
case '"':
buf.WriteRune('"')
default:
buf.WriteRune('\\')
buf.WriteRune(p.input[p.pos])
}
} else {
buf.WriteRune(p.input[p.pos])
}
p.pos++
}
if p.pos >= len(p.input) {
return nil, fmt.Errorf("line %d, col %d: unterminated string", p.line, p.col)
}
p.skip(1)
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprLiteral, Value: buf.String()}, nil
}
func (p *ExprParser) parseSingleQuoteStringLit() (*Expr, error) {
p.skip(1)
var buf strings.Builder
for p.pos < len(p.input) && p.input[p.pos] != '\'' {
buf.WriteRune(p.input[p.pos])
p.pos++
}
if p.pos >= len(p.input) {
return nil, fmt.Errorf("line %d, col %d: unterminated string", p.line, p.col)
}
p.skip(1)
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprLiteral, Value: buf.String()}, nil
}
func (p *ExprParser) parseNumberLit() (*Expr, error) {
start := p.pos
for p.pos < len(p.input) && p.input[p.pos] >= '0' && p.input[p.pos] <= '9' {
p.pos++
}
isFloat := false
if p.pos < len(p.input) && p.input[p.pos] == '.' {
next := p.pos + 1
if next < len(p.input) && p.input[next] >= '0' && p.input[next] <= '9' {
isFloat = true
p.pos++
for p.pos < len(p.input) && p.input[p.pos] >= '0' && p.input[p.pos] <= '9' {
p.pos++
}
}
}
text := string(p.input[start:p.pos])
if isFloat {
v, err := strconv.ParseFloat(text, 64)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: invalid number %q", p.line, p.col, text)
}
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprLiteral, Value: v}, nil
}
v, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: invalid number %q", p.line, p.col, text)
}
return &Expr{Pos: Pos{Line: p.line, Col: p.col}, ExprType: ExprLiteral, Value: int(v)}, nil
}
func (p *ExprParser) peekStr(s string) bool {
if p.pos+len(s) > len(p.input) {
return false
}
for i, ch := range s {
if p.input[p.pos+i] != ch {
return false
}
}
return true
}
func (p *ExprParser) skip(n int) {
p.pos += n
p.col += n
}
func (p *ExprParser) skipSpaces() {
for p.pos < len(p.input) && unicode.IsSpace(p.input[p.pos]) {
if p.input[p.pos] == '\n' {
p.line++
p.col = 0
} else {
p.col++
}
p.pos++
}
}
func isIdentStart(ch rune) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'
}
func isIdentPart(ch rune) bool {
return isIdentStart(ch) || (ch >= '0' && ch <= '9')
}
func Eval(expr *Expr, ctx *Context) (any, error) {
switch expr.ExprType {
case ExprLiteral:
return expr.Value, nil
case ExprNil:
return nil, nil
case ExprVariable:
val, ok := ctx.Get(expr.Name)
if !ok {
return nil, nil
}
return val, nil
case ExprUnary:
return evalUnary(expr, ctx)
case ExprBinary:
return evalBinary(expr, ctx)
case ExprFuncCall:
return evalFuncCall(expr, ctx)
default:
return nil, fmt.Errorf("line %d, col %d: unknown expression type", expr.Pos.Line, expr.Pos.Col)
}
}
func evalUnary(expr *Expr, ctx *Context) (any, error) {
val, err := Eval(expr.Operand, ctx)
if err != nil {
return nil, err
}
switch expr.UnaryOp {
case "!":
return !isTruthy(val), nil
default:
return nil, fmt.Errorf("line %d, col %d: unknown unary operator %q", expr.Pos.Line, expr.Pos.Col, expr.UnaryOp)
}
}
func evalBinary(expr *Expr, ctx *Context) (any, error) {
switch expr.Op {
case "&&":
left, err := Eval(expr.Left, ctx)
if err != nil {
return nil, err
}
if !isTruthy(left) {
return left, nil
}
return Eval(expr.Right, ctx)
case "||":
left, err := Eval(expr.Left, ctx)
if err != nil {
return nil, err
}
if isTruthy(left) {
return left, nil
}
return Eval(expr.Right, ctx)
}
left, err := Eval(expr.Left, ctx)
if err != nil {
return nil, err
}
right, err := Eval(expr.Right, ctx)
if err != nil {
return nil, err
}
switch expr.Op {
case "==":
return compareEqual(left, right), nil
case "!=":
return !compareEqual(left, right), nil
case "<":
return compareOrder(left, right, expr.Op)
case ">":
return compareOrder(left, right, expr.Op)
case "<=":
return compareOrder(left, right, expr.Op)
case ">=":
return compareOrder(left, right, expr.Op)
default:
return nil, fmt.Errorf("line %d, col %d: unknown operator %q", expr.Pos.Line, expr.Pos.Col, expr.Op)
}
}
func evalFuncCall(expr *Expr, ctx *Context) (any, error) {
fn, ok := LookupBuiltin(expr.FuncName)
if !ok {
return nil, fmt.Errorf("line %d, col %d: unknown function %q", expr.Pos.Line, expr.Pos.Col, expr.FuncName)
}
var args []any
for _, a := range expr.FuncArgs {
val, err := Eval(a, ctx)
if err != nil {
return nil, err
}
args = append(args, val)
}
result, ok := fn(args)
if !ok {
return nil, fmt.Errorf("line %d, col %d: function %q call failed", expr.Pos.Line, expr.Pos.Col, expr.FuncName)
}
return result, nil
}
func isTruthy(val any) bool {
if val == nil {
return false
}
switch v := val.(type) {
case bool:
return v
case int:
return v != 0
case int64:
return v != 0
case float64:
return v != 0
case string:
return v != ""
case []any:
return len(v) > 0
case map[string]any:
return len(v) > 0
default:
rv := reflect.ValueOf(val)
switch rv.Kind() {
case reflect.Slice, reflect.Array, reflect.Map:
return rv.Len() > 0
default:
return true
}
}
}
func compareEqual(left, right any) bool {
if left == nil && right == nil {
return true
}
if left == nil || right == nil {
return false
}
lbool, lbOk := left.(bool)
rbool, rbOk := right.(bool)
if lbOk || rbOk {
return lbOk && rbOk && lbool == rbool
}
lf, lok := toFloat64(left)
rf, rok := toFloat64(right)
if lok && rok {
return lf == rf
}
lstr, lsOk := left.(string)
rstr, rsOk := right.(string)
if lsOk && rsOk {
return lstr == rstr
}
return fmt.Sprintf("%v", left) == fmt.Sprintf("%v", right)
}
func compareOrder(left, right any, op string) (bool, error) {
if left == nil || right == nil {
return false, nil
}
lf, lok := toFloat64(left)
rf, rok := toFloat64(right)
if lok && rok {
switch op {
case "<":
return lf < rf, nil
case ">":
return lf > rf, nil
case "<=":
return lf <= rf, nil
case ">=":
return lf >= rf, nil
}
}
ls, lsOk := left.(string)
rs, rsOk := right.(string)
if lsOk && rsOk {
switch op {
case "<":
return ls < rs, nil
case ">":
return ls > rs, nil
case "<=":
return ls <= rs, nil
case ">=":
return ls >= rs, nil
}
}
return false, fmt.Errorf("line 0, col 0: cannot compare %T and %T with %s", left, right, op)
}
func toFloat64(val any) (float64, bool) {
switch v := val.(type) {
case int:
return float64(v), true
case int64:
return float64(v), true
case float64:
return v, true
case float32:
return float64(v), true
case uint:
return float64(v), true
case uint64:
return float64(v), true
case int32:
return float64(v), true
default:
return 0, false
}
}

73
internal/include.go Normal file
View File

@@ -0,0 +1,73 @@
package internal
import (
"fmt"
"strings"
)
type IncludeResolver func(path string) (string, error)
type IncludeManager struct {
resolver IncludeResolver
stack []string
}
func NewIncludeManager(resolver IncludeResolver) *IncludeManager {
return &IncludeManager{resolver: resolver}
}
func (m *IncludeManager) Resolve(path string) (string, error) {
return m.resolveInternal(path, m.stack)
}
func (m *IncludeManager) resolveInternal(path string, stack []string) (string, error) {
for i, p := range stack {
if p == path {
return "", fmt.Errorf("circular include detected: %s is already in the include chain at depth %d", path, i)
}
}
src, err := m.resolver(path)
if err != nil {
return "", fmt.Errorf("failed to resolve include %q: %w", path, err)
}
newStack := make([]string, len(stack)+1)
copy(newStack, stack)
newStack[len(stack)] = path
return m.expandIncludes(src, newStack)
}
func (m *IncludeManager) expandIncludes(src string, stack []string) (string, error) {
result := src
offset := 0
for {
start := strings.Index(result[offset:], `@include("`)
if start < 0 {
break
}
absStart := offset + start
pathStart := absStart + len(`@include("`)
end := strings.Index(result[pathStart:], `")`)
if end < 0 {
break
}
includePath := result[pathStart : pathStart+end]
absEnd := pathStart + end + len(`")`)
resolved, err := m.resolveInternal(includePath, stack)
if err != nil {
return "", err
}
result = result[:absStart] + resolved + result[absEnd:]
offset = absStart + len(resolved)
}
return result, nil
}

211
internal/lexer.go Normal file
View File

@@ -0,0 +1,211 @@
package internal
import (
"strings"
"unicode"
)
type TokenType int
const (
TokText TokenType = iota
TokParamStart
TokRawStart
TokIfStart
TokForStart
TokTplStart
TokIncludeStart
TokNamespaceStart
TokElse
TokComment
TokEOF
)
type Token struct {
Type TokenType
Value string
Pos Pos
}
type Lexer struct {
input []rune
pos int
line int
col int
}
func NewLexer(input string) *Lexer {
return &Lexer{
input: []rune(input),
line: 1,
col: 1,
}
}
func (l *Lexer) Tokenize() ([]Token, error) {
var tokens []Token
for l.pos < len(l.input) {
ch := l.input[l.pos]
if ch == '#' {
if l.peek(1) == '{' {
tokens = append(tokens, Token{Type: TokParamStart, Value: "#{", Pos: l.curPos()})
l.advance()
l.advance()
continue
}
tokens = append(tokens, l.readComment())
continue
}
if ch == '$' && l.peek(1) == '{' {
tokens = append(tokens, Token{Type: TokRawStart, Value: "${", Pos: l.curPos()})
l.advance()
l.advance()
continue
}
if ch == '@' {
if tok, ok := l.tryDirective(); ok {
tokens = append(tokens, tok)
continue
}
}
if ch == '}' {
// Skip spaces after '}' to check for "else"
spaceOffset := 1
for l.peek(spaceOffset) == ' ' || l.peek(spaceOffset) == '\t' {
spaceOffset++
}
if l.peekWord(spaceOffset, "else") {
pos := l.curPos()
l.advance() // consume '}'
l.advanceN(spaceOffset - 1) // consume spaces
l.advanceN(4) // consume "else"
tokens = append(tokens, Token{Type: TokElse, Value: "} else", Pos: pos})
continue
}
l.advance()
// Pos stores the end position (after '}'), consistent with other TokText tokens
tokens = append(tokens, Token{Type: TokText, Value: "}", Pos: l.curPos()})
continue
}
if ch == '\n' {
l.advance()
// Pos stores the end position (after '\n'), consistent with other TokText tokens
tokens = append(tokens, Token{Type: TokText, Value: "\n", Pos: l.curPos()})
continue
}
// Regular text: scan until special character
start := l.pos
for l.pos < len(l.input) {
c := l.input[l.pos]
if c == '#' || c == '$' || c == '@' || c == '}' || c == '\n' {
break
}
l.advance()
}
if l.pos > start {
tokens = append(tokens, Token{Type: TokText, Value: string(l.input[start:l.pos]), Pos: Pos{Line: l.line, Col: l.col}})
}
}
tokens = append(tokens, Token{Type: TokEOF, Pos: Pos{Line: l.line, Col: l.col}})
return tokens, nil
}
func (l *Lexer) curPos() Pos {
return Pos{Line: l.line, Col: l.col}
}
func (l *Lexer) advance() {
if l.pos < len(l.input) {
if l.input[l.pos] == '\n' {
l.line++
l.col = 1
} else {
l.col++
}
l.pos++
}
}
func (l *Lexer) advanceN(n int) {
for range n {
l.advance()
}
}
func (l *Lexer) peek(offset int) rune {
idx := l.pos + offset
if idx < len(l.input) {
return l.input[idx]
}
return 0
}
func (l *Lexer) peekWord(offset int, word string) bool {
runes := []rune(word)
n := len(runes)
for i := range n {
if l.peek(offset+i) != runes[i] {
return false
}
}
after := l.peek(offset + n)
return after == 0 || after == ' ' || after == '\n' || after == '\t' || after == '{' || after == '}'
}
func (l *Lexer) tryDirective() (Token, bool) {
type directive struct {
prefix []rune
ttype TokenType
skip int
}
directives := []directive{
{[]rune("@if("), TokIfStart, 4},
{[]rune("@for("), TokForStart, 5},
{[]rune("@tpl(\""), TokTplStart, 5},
{[]rune("@include(\""), TokIncludeStart, 10},
{[]rune("@namespace(\""), TokNamespaceStart, 12},
}
for _, d := range directives {
if l.matchRunes(d.prefix) {
pos := l.curPos()
val := string(d.prefix)
l.advanceN(d.skip)
return Token{Type: d.ttype, Value: val, Pos: pos}, true
}
}
return Token{}, false
}
func (l *Lexer) matchRunes(runes []rune) bool {
for i, r := range runes {
if l.peek(i) != r {
return false
}
}
return true
}
func (l *Lexer) readComment() Token {
pos := l.curPos()
l.advance() // #
start := l.pos
for l.pos < len(l.input) && l.input[l.pos] != '\n' {
l.advance()
}
// consume trailing newline so the comment line disappears
if l.pos < len(l.input) && l.input[l.pos] == '\n' {
l.advance()
}
return Token{Type: TokComment, Value: strings.TrimRightFunc(string(l.input[start:l.pos]), unicode.IsSpace), Pos: pos}
}

111
internal/node.go Normal file
View File

@@ -0,0 +1,111 @@
package internal
type Node interface {
nodeType() string
}
type Pos struct {
Line int
Col int
}
type TextNode struct {
Pos Pos
Text string
}
func (n *TextNode) nodeType() string { return "Text" }
type ParamNode struct {
Pos Pos
Name string
}
func (n *ParamNode) nodeType() string { return "Param" }
type RawNode struct {
Pos Pos
Name string
}
func (n *RawNode) nodeType() string { return "Raw" }
type IfNode struct {
Pos Pos
Cond *Expr
Body []Node
Else []Node
ElseIf []*ElseIfBranch
}
func (n *IfNode) nodeType() string { return "If" }
type ElseIfBranch struct {
Pos Pos
Cond *Expr
Body []Node
}
type ForNode struct {
Pos Pos
KeyVar string
ValVar string
List *Expr
Body []Node
}
func (n *ForNode) nodeType() string { return "For" }
type BlockNode struct {
Pos Pos
Name string
Body []Node
}
func (n *BlockNode) nodeType() string { return "Block" }
type IncludeNode struct {
Pos Pos
Path string
}
func (n *IncludeNode) nodeType() string { return "Include" }
type NamespaceNode struct {
Pos Pos
Name string
}
func (n *NamespaceNode) nodeType() string { return "Namespace" }
type CommentNode struct {
Pos Pos
Text string
}
func (n *CommentNode) nodeType() string { return "Comment" }
type ExprType int
const (
ExprLiteral ExprType = iota
ExprVariable
ExprBinary
ExprUnary
ExprFuncCall
ExprNil
)
type Expr struct {
Pos Pos
ExprType ExprType
Name string
Value any
Left *Expr
Op string
Right *Expr
UnaryOp string
Operand *Expr
FuncName string
FuncArgs []*Expr
}

913
internal/parser.go Normal file
View File

@@ -0,0 +1,913 @@
package internal
import (
"fmt"
"strings"
)
type Parser struct {
input []rune
tokens []Token
pos int
includeMgr *IncludeManager
}
func NewParser(input string, tokens []Token, includeMgr *IncludeManager) *Parser {
return &Parser{
input: []rune(input),
tokens: tokens,
pos: 0,
includeMgr: includeMgr,
}
}
func (p *Parser) Parse() ([]Node, error) {
var nodes []Node
var hasTpl bool
var tplNames map[string]bool
for p.pos < len(p.tokens) {
tok := p.cur()
if tok.Type == TokEOF {
break
}
switch tok.Type {
case TokText:
p.pos++
if len(tok.Value) == 0 {
continue
}
if hasTpl && !isWhitespace(tok.Value) {
return nil, fmt.Errorf("line %d, col %d: top-level text is not allowed in templates with @tpl blocks", tok.Pos.Line, tok.Pos.Col)
}
nodes = append(nodes, &TextNode{Pos: tok.Pos, Text: tok.Value})
case TokParamStart:
node, err := p.parseParam(tok)
if err != nil {
return nil, err
}
nodes = append(nodes, node)
case TokRawStart:
node, err := p.parseRaw(tok)
if err != nil {
return nil, err
}
nodes = append(nodes, node)
case TokIfStart:
node, err := p.parseIf(tok)
if err != nil {
return nil, err
}
nodes = append(nodes, node)
case TokForStart:
node, err := p.parseFor(tok)
if err != nil {
return nil, err
}
nodes = append(nodes, node)
case TokTplStart:
hasTpl = true
if tplNames == nil {
tplNames = make(map[string]bool)
}
node, err := p.parseTpl(tok, tplNames)
if err != nil {
return nil, err
}
nodes = append(nodes, node)
case TokIncludeStart:
subNodes, err := p.expandInclude(tok)
if err != nil {
return nil, err
}
nodes = append(nodes, subNodes...)
case TokNamespaceStart:
node, err := p.parseNamespace(tok, len(nodes))
if err != nil {
return nil, err
}
nodes = append(nodes, node)
case TokElse:
return nil, fmt.Errorf("line %d, col %d: unexpected else", tok.Pos.Line, tok.Pos.Col)
case TokComment:
p.pos++
default:
p.pos++
}
}
return nodes, nil
}
func (p *Parser) cur() Token {
if p.pos < len(p.tokens) {
return p.tokens[p.pos]
}
return Token{Type: TokEOF}
}
// readUntilParen reads from runePos until ')' at paren depth 0.
// Only tracks '(' depth, not braces.
func (p *Parser) readUntilParen(startRunePos int) (string, int, error) {
i := startRunePos
depth := 0
for i < len(p.input) {
ch := p.input[i]
if ch == '(' {
depth++
i++
continue
}
if ch == ')' {
if depth > 0 {
depth--
i++
continue
}
return string(p.input[startRunePos:i]), i, nil
}
if ch == '\'' || ch == '"' {
quote := ch
i++
for i < len(p.input) && p.input[i] != quote {
if p.input[i] == '\\' && i+1 < len(p.input) {
i++
}
i++
}
if i < len(p.input) {
i++
}
continue
}
i++
}
return "", i, fmt.Errorf("unexpected end of input, expected ')'")
}
// readUntilBrace reads from runePos until '}' at brace depth 0.
// Only tracks '{' depth, not parens.
func (p *Parser) readUntilBrace(startRunePos int) (string, int, error) {
i := startRunePos
depth := 0
for i < len(p.input) {
ch := p.input[i]
if ch == '{' {
depth++
i++
continue
}
if ch == '}' {
if depth > 0 {
depth--
i++
continue
}
return string(p.input[startRunePos:i]), i, nil
}
if ch == '\'' || ch == '"' {
quote := ch
i++
for i < len(p.input) && p.input[i] != quote {
if p.input[i] == '\\' && i+1 < len(p.input) {
i++
}
i++
}
if i < len(p.input) {
i++
}
continue
}
i++
}
return "", i, fmt.Errorf("unexpected end of input, expected '}'")
}
// readUntilQuote reads from runePos until '"'.
func (p *Parser) readUntilQuote(startRunePos int) (string, int, error) {
i := startRunePos
for i < len(p.input) {
if p.input[i] == '"' {
return string(p.input[startRunePos:i]), i, nil
}
i++
}
return "", i, fmt.Errorf("unexpected end of input, expected '\"'")
}
func (p *Parser) runePosFromToken(tok Token) int {
line, col := tok.Pos.Line, tok.Pos.Col
prefixLen := len(tok.Value)
return runePosFromLineCol(p.input, line, col) + prefixLen
}
func runePosFromLineCol(input []rune, line, col int) int {
if line <= 1 && col <= 1 {
return 0
}
currentLine := 1
currentCol := 1
for i, ch := range input {
if currentLine == line && currentCol == col {
return i
}
if ch == '\n' {
currentLine++
currentCol = 1
} else {
currentCol++
}
}
return len(input)
}
func (p *Parser) consumeTokensForRuneRange(_, runeEnd int) {
for p.pos < len(p.tokens) {
tok := p.tokens[p.pos]
if tok.Type == TokEOF {
break
}
// Text tokens store Pos as the end position; other tokens store Pos as the start.
tokRuneStart := runePosFromLineCol(p.input, tok.Pos.Line, tok.Pos.Col)
if tok.Type == TokText {
tokRuneStart -= len(tok.Value)
}
if tokRuneStart >= runeEnd {
break
}
// For text tokens, check if the token extends past runeEnd.
// If so, split it: keep the remainder as a new text token.
if tok.Type == TokText && len(tok.Value) > 0 {
tokRuneEnd := tokRuneStart + len(tok.Value)
if tokRuneEnd > runeEnd {
overlap := runeEnd - tokRuneStart
if overlap > 0 && overlap < len(tok.Value) {
remainder := tok.Value[overlap:]
// Replace current token with the remainder
p.tokens[p.pos] = Token{
Type: TokText,
Value: remainder,
Pos: tok.Pos,
}
}
break
}
}
p.pos++
}
}
func (p *Parser) parseParam(tok Token) (*ParamNode, error) {
runePos := p.runePosFromToken(tok)
content, endPos, err := p.readUntilBrace(runePos)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: unterminated param, expected '}'", tok.Pos.Line, tok.Pos.Col)
}
content = strings.TrimSpace(content)
if content == "" {
return nil, fmt.Errorf("line %d, col %d: empty param name", tok.Pos.Line, tok.Pos.Col)
}
p.consumeTokensForRuneRange(runePos, endPos+1)
return &ParamNode{Pos: tok.Pos, Name: content}, nil
}
func (p *Parser) parseRaw(tok Token) (*RawNode, error) {
runePos := p.runePosFromToken(tok)
content, endPos, err := p.readUntilBrace(runePos)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: unterminated raw, expected '}'", tok.Pos.Line, tok.Pos.Col)
}
content = strings.TrimSpace(content)
if content == "" {
return nil, fmt.Errorf("line %d, col %d: empty raw name", tok.Pos.Line, tok.Pos.Col)
}
p.consumeTokensForRuneRange(runePos, endPos+1)
return &RawNode{Pos: tok.Pos, Name: content}, nil
}
func (p *Parser) parseIf(tok Token) (*IfNode, error) {
runePos := p.runePosFromToken(tok)
exprStr, parenClose, err := p.readUntilParen(runePos)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: unterminated @if, expected ')'", tok.Pos.Line, tok.Pos.Col)
}
expr, err := NewExprParser(strings.TrimSpace(exprStr), tok.Pos.Line, tok.Pos.Col).Parse()
if err != nil {
return nil, err
}
// Find '{' after ')' in raw input
braceOpen := findChar(p.input[parenClose+1:], '{')
if braceOpen < 0 {
return nil, fmt.Errorf("line %d, col %d: expected '{' after @if condition", tok.Pos.Line, tok.Pos.Col)
}
braceOpen = parenClose + 1 + braceOpen
// consume tokens covering ) ... {
p.consumeTokensForRuneRange(runePos, braceOpen+1)
body, elseBody, elseIfBranches, err := p.parseBlockBodyWithElse("if")
if err != nil {
return nil, err
}
return &IfNode{
Pos: tok.Pos,
Cond: expr,
Body: body,
Else: elseBody,
ElseIf: elseIfBranches,
}, nil
}
func (p *Parser) parseElseIfBranch(tok Token) (*ElseIfBranch, error) {
prefix := "@elseif("
runePos := runePosFromLineCol(p.input, tok.Pos.Line, tok.Pos.Col)
idx := strings.Index(tok.Value, prefix)
if idx < 0 {
return nil, fmt.Errorf("line %d, col %d: expected @elseif(", tok.Pos.Line, tok.Pos.Col)
}
exprStart := runePos + idx + len(prefix)
exprStr, closePos, err := p.readUntilParen(exprStart)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: unterminated @elseif, expected ')'", tok.Pos.Line, tok.Pos.Col)
}
expr, err := NewExprParser(strings.TrimSpace(exprStr), tok.Pos.Line, tok.Pos.Col).Parse()
if err != nil {
return nil, err
}
p.consumeTokensForRuneRange(exprStart, closePos+1)
p.skipToBraceOpen()
body, _, _, err := p.parseBlockBodyWithElse("elseif")
if err != nil {
return nil, err
}
return &ElseIfBranch{
Pos: tok.Pos,
Cond: expr,
Body: body,
}, nil
}
func (p *Parser) parseFor(tok Token) (*ForNode, error) {
runePos := p.runePosFromToken(tok)
content, parenClose, err := p.readUntilParen(runePos)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: unterminated @for, expected ')'", tok.Pos.Line, tok.Pos.Col)
}
braceOpen := findChar(p.input[parenClose+1:], '{')
if braceOpen < 0 {
return nil, fmt.Errorf("line %d, col %d: expected '{' after @for", tok.Pos.Line, tok.Pos.Col)
}
braceOpen = parenClose + 1 + braceOpen
p.consumeTokensForRuneRange(runePos, braceOpen+1)
keyVar, valVar, listExprStr, err := parseForHeader(content)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: %s", tok.Pos.Line, tok.Pos.Col, err.Error())
}
listExpr, err := NewExprParser(strings.TrimSpace(listExprStr), tok.Pos.Line, tok.Pos.Col).Parse()
if err != nil {
return nil, err
}
body, _, _, err := p.parseBlockBodyWithElse("for")
if err != nil {
return nil, err
}
return &ForNode{
Pos: tok.Pos,
KeyVar: keyVar,
ValVar: valVar,
List: listExpr,
Body: body,
}, nil
}
func findChar(input []rune, ch rune) int {
for i, c := range input {
if c == ch {
return i
}
}
return -1
}
func parseForHeader(content string) (keyVar, valVar, listExpr string, err error) {
content = strings.TrimSpace(content)
rangeIdx := strings.Index(content, " range ")
if rangeIdx < 0 {
return "", "", "", fmt.Errorf("expected 'range' keyword in @for")
}
varsPart := strings.TrimRight(strings.TrimSpace(content[:rangeIdx]), ", ")
listExpr = strings.TrimSpace(content[rangeIdx+len(" range "):])
parts := strings.Split(varsPart, ",")
switch len(parts) {
case 1:
valVar = strings.TrimSpace(parts[0])
case 2:
keyVar = strings.TrimSpace(parts[0])
valVar = strings.TrimSpace(parts[1])
default:
return "", "", "", fmt.Errorf("invalid @for variable declaration")
}
if valVar == "" {
return "", "", "", fmt.Errorf("missing value variable in @for")
}
return keyVar, valVar, listExpr, nil
}
func (p *Parser) parseTpl(tok Token, tplNames map[string]bool) (*BlockNode, error) {
runePos := p.runePosFromToken(tok)
name, quotePos, err := p.readUntilQuote(runePos)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: unterminated @tpl name", tok.Pos.Line, tok.Pos.Col)
}
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("line %d, col %d: empty @tpl block name", tok.Pos.Line, tok.Pos.Col)
}
if tplNames[name] {
return nil, fmt.Errorf("line %d, col %d: duplicate @tpl block name %q", tok.Pos.Line, tok.Pos.Col, name)
}
tplNames[name] = true
// skip ") {"
endPos := quotePos + 1 // past closing "
if endPos < len(p.input) && p.input[endPos] == ')' {
endPos++
}
// find '{' and consume tokens up to past it
braceOpen := findChar(p.input[endPos:], '{')
if braceOpen < 0 {
return nil, fmt.Errorf("line %d, col %d: expected '{' after @tpl", tok.Pos.Line, tok.Pos.Col)
}
braceOpen = endPos + braceOpen
p.consumeTokensForRuneRange(runePos, braceOpen+1)
body, _, _, err := p.parseBlockBodyWithElse("tpl")
if err != nil {
return nil, err
}
return &BlockNode{
Pos: tok.Pos,
Name: name,
Body: body,
}, nil
}
func (p *Parser) parseInclude(tok Token) (*IncludeNode, error) {
runePos := p.runePosFromToken(tok)
path, quotePos, err := p.readUntilQuote(runePos)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: unterminated @include path", tok.Pos.Line, tok.Pos.Col)
}
path = strings.TrimSpace(path)
endPos := quotePos + 1
if endPos < len(p.input) && p.input[endPos] == ')' {
endPos++
}
p.consumeTokensForRuneRange(runePos, endPos)
return &IncludeNode{
Pos: tok.Pos,
Path: path,
}, nil
}
func (p *Parser) expandInclude(tok Token) ([]Node, error) {
incNode, err := p.parseInclude(tok)
if err != nil {
return nil, err
}
if p.includeMgr == nil {
return nil, fmt.Errorf("line %d, col %d: @include used but no include resolver configured", incNode.Pos.Line, incNode.Pos.Col)
}
expanded, err := p.includeMgr.Resolve(incNode.Path)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: %s", incNode.Pos.Line, incNode.Pos.Col, err.Error())
}
subLexer := NewLexer(expanded)
subTokens, err := subLexer.Tokenize()
if err != nil {
return nil, err
}
subParser := NewParser(expanded, subTokens, p.includeMgr)
return subParser.Parse()
}
func (p *Parser) parseNamespace(tok Token, nodeCount int) (*NamespaceNode, error) {
if nodeCount > 0 {
return nil, fmt.Errorf("line %d, col %d: @namespace must be at the top of the file", tok.Pos.Line, tok.Pos.Col)
}
runePos := p.runePosFromToken(tok)
name, quotePos, err := p.readUntilQuote(runePos)
if err != nil {
return nil, fmt.Errorf("line %d, col %d: unterminated @namespace name", tok.Pos.Line, tok.Pos.Col)
}
name = strings.TrimSpace(name)
endPos := quotePos + 1
if endPos < len(p.input) && p.input[endPos] == ')' {
endPos++
}
p.consumeTokensForRuneRange(runePos, endPos)
return &NamespaceNode{
Pos: tok.Pos,
Name: name,
}, nil
}
// skipToBraceOpen finds '{' in raw input starting from current token position,
// then consumes all tokens up to and past '{'. The text after '{' is preserved
// as the current token so it becomes part of the block body.
func (p *Parser) skipToBraceOpen() {
// Find current rune position from current token
if p.pos >= len(p.tokens) {
return
}
tok := p.tokens[p.pos]
startRune := runePosFromLineCol(p.input, tok.Pos.Line, tok.Pos.Col)
// Text tokens store Pos as the end position; adjust to start.
if tok.Type == TokText {
startRune -= len(tok.Value)
}
// Find '{' in raw input
idx := findChar(p.input[startRune:], '{')
if idx < 0 {
return
}
braceRune := startRune + idx
// Consume all tokens whose rune position is before '{'
p.consumeTokensForRuneRange(startRune, braceRune+1)
// The text after '{' needs to be available as a token.
// If there are remaining characters after '{', they'll be in subsequent tokens.
}
// splitTextAtBrace finds the first '{' in the current text token at p.pos,
// splits it so the part after '{' remains, and returns true.
// If no '{' is found, advances p.pos and returns false.
func (p *Parser) splitTextAtBrace() bool {
for p.pos < len(p.tokens) {
tok := p.tokens[p.pos]
if tok.Type != TokText {
return false
}
idx := strings.Index(tok.Value, "{")
if idx >= 0 {
if idx+1 < len(tok.Value) {
remainder := tok.Value[idx+1:]
p.tokens[p.pos] = Token{Type: TokText, Value: remainder, Pos: tok.Pos}
} else {
p.pos++
}
return true
}
p.pos++
}
return false
}
func (p *Parser) skipWhitespaceText() {
for p.pos < len(p.tokens) {
tok := p.tokens[p.pos]
if tok.Type == TokEOF {
break
}
if tok.Type == TokText && isWhitespace(tok.Value) {
p.pos++
continue
}
break
}
}
// parseBlockBodyWithElse parses tokens inside a { } block.
// Returns: body nodes, else body (nil if no else), elseif branches, error.
// Handles nested @if/@for blocks by tracking brace depth.
func (p *Parser) parseBlockBodyWithElse(blockType string) ([]Node, []Node, []*ElseIfBranch, error) {
var body []Node
braceDepth := 1
for p.pos < len(p.tokens) {
tok := p.cur()
if tok.Type == TokEOF {
return nil, nil, nil, fmt.Errorf("line %d, col %d: unterminated %s block", tok.Pos.Line, tok.Pos.Col, blockType)
}
if tok.Type == TokText {
text := tok.Value
// Scan for '}' at depth 1
before, found := splitAtClosingBrace(text)
if found {
if before != "" {
body = append(body, &TextNode{Pos: tok.Pos, Text: before})
}
p.pos++ // consume this token
// Check what follows: else, elseif, or nothing
return p.handleBlockEnd(body, blockType)
}
// No closing brace — count nested braces and add as text
for _, ch := range text {
if ch == '{' {
braceDepth++
} else if ch == '}' {
braceDepth--
}
}
if len(text) > 0 {
body = append(body, &TextNode{Pos: tok.Pos, Text: text})
}
p.pos++
continue
}
if tok.Type == TokElse && braceDepth == 1 {
// Don't consume TokElse here — handleBlockEnd will consume it.
// This way handleBlockEnd can properly process the else branch.
return p.handleBlockEnd(body, blockType)
}
p.pos++
switch tok.Type {
case TokComment:
// skip comments in body
case TokParamStart:
node, err := p.parseParam(tok)
if err != nil {
return nil, nil, nil, err
}
body = append(body, node)
case TokRawStart:
node, err := p.parseRaw(tok)
if err != nil {
return nil, nil, nil, err
}
body = append(body, node)
case TokIfStart:
node, err := p.parseIf(tok)
if err != nil {
return nil, nil, nil, err
}
body = append(body, node)
case TokForStart:
node, err := p.parseFor(tok)
if err != nil {
return nil, nil, nil, err
}
body = append(body, node)
case TokTplStart:
return nil, nil, nil, fmt.Errorf("line %d, col %d: @tpl blocks cannot be nested", tok.Pos.Line, tok.Pos.Col)
case TokIncludeStart:
subNodes, err := p.expandInclude(tok)
if err != nil {
return nil, nil, nil, err
}
body = append(body, subNodes...)
case TokNamespaceStart:
return nil, nil, nil, fmt.Errorf("line %d, col %d: @namespace must be at file top level", tok.Pos.Line, tok.Pos.Col)
}
}
return nil, nil, nil, fmt.Errorf("unterminated %s block", blockType)
}
// handleBlockEnd is called after the closing '}' of a block.
// Checks for else/elseif branches.
func (p *Parser) handleBlockEnd(body []Node, blockType string) ([]Node, []Node, []*ElseIfBranch, error) {
// For non-if blocks, no else support
if blockType != "if" && blockType != "elseif" {
return body, nil, nil, nil
}
p.skipWhitespaceText()
tok := p.cur()
// TokElse means lexer already matched "} else"
if tok.Type == TokElse {
return p.handleTokElse(tok, body)
}
// Check for text token starting with "else" or "elseif("
if tok.Type == TokText {
return p.handleTextElse(tok, body)
}
return body, nil, nil, nil
}
// handleTokElse processes a TokElse token (lexer already matched "} else").
func (p *Parser) handleTokElse(tok Token, body []Node) ([]Node, []Node, []*ElseIfBranch, error) {
p.pos++ // consume TokElse
p.skipWhitespaceText()
next := p.cur()
// Check for "} else if(expr)" — text starting with "if " or "if("
if next.Type == TokText {
trimmed := strings.TrimSpace(next.Value)
if strings.HasPrefix(trimmed, "if ") || strings.HasPrefix(trimmed, "if(") {
return p.parseElseIfFromText(body, next)
}
}
// Check for @elseif( syntax
if next.Type == TokText && strings.Contains(next.Value, "@elseif(") {
branch, err := p.parseElseIfBranch(next)
if err != nil {
return nil, nil, nil, err
}
return body, nil, []*ElseIfBranch{branch}, nil
}
// It's a plain else — find '{' in text tokens and split
if !p.splitTextAtBrace() {
return nil, nil, nil, fmt.Errorf("line %d, col %d: expected '{' after else", tok.Pos.Line, tok.Pos.Col)
}
elseBody, _, _, err := p.parseBlockBodyWithElse("else")
if err != nil {
return nil, nil, nil, err
}
return body, elseBody, nil, nil
}
// handleTextElse processes a TokText token that may contain "else" or "elseif(".
func (p *Parser) handleTextElse(tok Token, body []Node) ([]Node, []Node, []*ElseIfBranch, error) {
trimmed := strings.TrimSpace(tok.Value)
// Handle "else if" in a single text token (e.g. "} else if (expr) {")
if strings.HasPrefix(trimmed, "else if ") || strings.HasPrefix(trimmed, "else if(") {
p.consumeElseKeyword(tok)
p.skipWhitespaceText()
next := p.cur()
if next.Type == TokText {
return p.parseElseIfFromText(body, next)
}
}
// Plain else
if strings.HasPrefix(trimmed, "else") && !strings.HasPrefix(trimmed, "elseif(") && !strings.HasPrefix(trimmed, "else if ") && !strings.HasPrefix(trimmed, "else if(") {
p.consumeElseKeyword(tok)
if !p.splitTextAtBrace() {
p.skipToBraceOpen()
}
elseBody, _, _, err := p.parseBlockBodyWithElse("else")
if err != nil {
return nil, nil, nil, err
}
return body, elseBody, nil, nil
}
// elseif( syntax
if strings.HasPrefix(trimmed, "elseif(") {
branch, err := p.parseElseIfBranch(tok)
if err != nil {
return nil, nil, nil, err
}
return body, nil, []*ElseIfBranch{branch}, nil
}
return body, nil, nil, nil
}
// consumeElseKeyword removes the "else" prefix from a text token,
// keeping any remaining text as the current token.
func (p *Parser) consumeElseKeyword(tok Token) {
idx := strings.Index(tok.Value, "else")
if idx >= 0 {
remainder := tok.Value[idx+4:] // after "else"
if len(remainder) > 0 {
p.tokens[p.pos] = Token{Type: TokText, Value: remainder, Pos: tok.Pos}
} else {
p.pos++
}
} else {
p.pos++
}
}
// parseElseIfFromText handles "} else if (expr) { body }" pattern.
// next is a TokText whose trimmed value starts with "if(" or "if ".
func (p *Parser) parseElseIfFromText(body []Node, next Token) ([]Node, []Node, []*ElseIfBranch, error) {
trimmed := strings.TrimSpace(next.Value)
// Find "if" and skip to the opening paren
ifIdx := strings.Index(trimmed, "if")
if ifIdx < 0 {
return nil, nil, nil, fmt.Errorf("line %d, col %d: expected 'if' in else-if condition", next.Pos.Line, next.Pos.Col)
}
afterIf := trimmed[ifIdx+2:]
// skip whitespace between "if" and "("
parenLocalOffset := 0
for parenLocalOffset < len(afterIf) && afterIf[parenLocalOffset] == ' ' {
parenLocalOffset++
}
if parenLocalOffset >= len(afterIf) || afterIf[parenLocalOffset] != '(' {
return nil, nil, nil, fmt.Errorf("line %d, col %d: expected '(' after 'else if'", next.Pos.Line, next.Pos.Col)
}
// Compute rune position of '(' in the raw input.
// The text token's Pos marks the END of the token text; the start is Pos - len(Value).
runePos := runePosFromLineCol(p.input, next.Pos.Line, next.Pos.Col) - len(next.Value)
// Walk through next.Value byte-by-byte to find the '(' that corresponds to the condition.
// We know trimmed starts at some offset into next.Value; find "if" in the raw value.
rawIfIdx := strings.Index(next.Value, "if")
rawParenOffset := rawIfIdx + 2
for rawParenOffset < len(next.Value) && next.Value[rawParenOffset] == ' ' {
rawParenOffset++
}
if rawParenOffset >= len(next.Value) || next.Value[rawParenOffset] != '(' {
return nil, nil, nil, fmt.Errorf("line %d, col %d: expected '(' after 'else if'", next.Pos.Line, next.Pos.Col)
}
// parenRunePos points to '(' in the raw input
parenRunePos := runePos + rawParenOffset
// readUntilParen expects the position AFTER '(' — it reads content between start and closing ')'
exprStr, parenClose, err := p.readUntilParen(parenRunePos + 1)
if err != nil {
return nil, nil, nil, fmt.Errorf("line %d, col %d: unterminated else-if condition, expected ')'", next.Pos.Line, next.Pos.Col)
}
expr, err := NewExprParser(strings.TrimSpace(exprStr), next.Pos.Line, next.Pos.Col).Parse()
if err != nil {
return nil, nil, nil, err
}
// Consume tokens from the start of this text token up to past ')'
p.consumeTokensForRuneRange(runePos, parenClose+1)
// Find '{' after ')' in raw input
braceOpen := findChar(p.input[parenClose+1:], '{')
if braceOpen < 0 {
return nil, nil, nil, fmt.Errorf("line %d, col %d: expected '{' after else-if condition", next.Pos.Line, next.Pos.Col)
}
braceOpen = parenClose + 1 + braceOpen
// Consume tokens up to past '{'
p.consumeTokensForRuneRange(parenClose+1, braceOpen+1)
// Parse the elseif body — it may itself have else/elseif
elseifBody, elseBody, moreBranches, err := p.parseBlockBodyWithElse("elseif")
if err != nil {
return nil, nil, nil, err
}
branch := &ElseIfBranch{
Pos: next.Pos,
Cond: expr,
Body: elseifBody,
}
allBranches := []*ElseIfBranch{branch}
allBranches = append(allBranches, moreBranches...)
return body, elseBody, allBranches, nil
}
// splitAtClosingBrace splits text at the first '}' at brace depth 0.
// Returns: text before '}', whether '}' was found.
func splitAtClosingBrace(text string) (string, bool) {
depth := 0
for i, ch := range text {
if ch == '{' {
depth++
} else if ch == '}' {
if depth == 0 {
return text[:i], true
}
depth--
}
}
return text, false
}
func isWhitespace(s string) bool {
return strings.TrimSpace(s) == ""
}

40
internal/placeholder.go Normal file
View File

@@ -0,0 +1,40 @@
package internal
import "fmt"
type PlaceholderStyle int
const (
QuestionMark PlaceholderStyle = iota
DollarNumber
ColonNumber
)
type Placeholder struct {
style PlaceholderStyle
count int
}
func NewPlaceholder(style PlaceholderStyle) *Placeholder {
return &Placeholder{style: style}
}
func (p *Placeholder) Next() string {
p.count++
switch p.style {
case DollarNumber:
return fmt.Sprintf("$%d", p.count)
case ColonNumber:
return fmt.Sprintf(":%d", p.count)
default:
return "?"
}
}
func (p *Placeholder) Reset() {
p.count = 0
}
func (p *Placeholder) Count() int {
return p.count
}