Files
u-tpl/internal/parser.go
绝尘 861d58d718 新增: u-tpl SQL 模板引擎完整实现
- Lexer/Parser/Executor 三阶段架构
- #{param} 参数化 + ${raw} 原样替换 + 白名单安全策略
- @if/@for/@tpl/@include/@namespace 控制流
- 表达式引擎: 比较、逻辑、nil 检查、len() 内置函数
- 支持 ?/$1/:1 多数据库占位符风格
- 零依赖,纯 Go 标准库实现
2026-04-01 00:27:50 +08:00

914 lines
24 KiB
Go

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) == ""
}