935 lines
24 KiB
Go
935 lines
24 KiB
Go
package internal
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "top-level text is not allowed in templates with @tpl blocks")
|
|
}
|
|
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 TokUseStart:
|
|
node, err := p.parseUse(tok)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nodes = append(nodes, node)
|
|
|
|
case TokElse:
|
|
return nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "unexpected else")
|
|
|
|
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 -= utf8.RuneCountInString(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 {
|
|
runes := []rune(tok.Value)
|
|
tokRuneEnd := tokRuneStart + len(runes)
|
|
if tokRuneEnd > runeEnd {
|
|
overlap := runeEnd - tokRuneStart
|
|
if overlap > 0 && overlap < len(runes) {
|
|
remainder := string(runes[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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "unterminated param, expected '}'")
|
|
}
|
|
content = strings.TrimSpace(content)
|
|
if content == "" {
|
|
return nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "empty param name")
|
|
}
|
|
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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "unterminated raw, expected '}'")
|
|
}
|
|
content = strings.TrimSpace(content)
|
|
if content == "" {
|
|
return nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "empty raw name")
|
|
}
|
|
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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "unterminated @if, expected ')'")
|
|
}
|
|
|
|
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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "expected '{' after @if condition")
|
|
}
|
|
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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "expected @elseif(")
|
|
}
|
|
exprStart := runePos + idx + len(prefix)
|
|
exprStr, closePos, err := p.readUntilParen(exprStart)
|
|
if err != nil {
|
|
return nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "unterminated @elseif, expected ')'")
|
|
}
|
|
|
|
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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "unterminated @for, expected ')'")
|
|
}
|
|
|
|
braceOpen := findChar(p.input[parenClose+1:], '{')
|
|
if braceOpen < 0 {
|
|
return nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "expected '{' after @for")
|
|
}
|
|
braceOpen = parenClose + 1 + braceOpen
|
|
p.consumeTokensForRuneRange(runePos, braceOpen+1)
|
|
|
|
keyVar, valVar, listExprStr, err := parseForHeader(content)
|
|
if err != nil {
|
|
return nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "%s", 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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "unterminated @tpl name")
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "empty @tpl block name")
|
|
}
|
|
if tplNames[name] {
|
|
return nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "duplicate @tpl block name %q", 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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "expected '{' after @tpl")
|
|
}
|
|
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) {
|
|
name, err := p.parseQuotedName(tok, "@include path")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &IncludeNode{Pos: tok.Pos, Path: name}, 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, PosErrorf(incNode.Pos.Line, incNode.Pos.Col, "@include used but no include resolver configured")
|
|
}
|
|
expanded, err := p.includeMgr.Resolve(incNode.Path)
|
|
if err != nil {
|
|
return nil, PosErrorf(incNode.Pos.Line, incNode.Pos.Col, "%s", err.Error())
|
|
}
|
|
subLexer := NewLexer(expanded)
|
|
subTokens, err := subLexer.Tokenize()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
subParser := NewParser(expanded, subTokens, p.includeMgr)
|
|
return subParser.Parse()
|
|
}
|
|
|
|
// parseQuotedName reads a quoted name from the directive token position.
|
|
// Shared by @include, @use, and @namespace.
|
|
func (p *Parser) parseQuotedName(tok Token, desc string) (string, error) {
|
|
runePos := p.runePosFromToken(tok)
|
|
name, quotePos, err := p.readUntilQuote(runePos)
|
|
if err != nil {
|
|
return "", PosErrorf(tok.Pos.Line, tok.Pos.Col, "unterminated %s", desc)
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
|
|
endPos := quotePos + 1
|
|
if endPos < len(p.input) && p.input[endPos] == ')' {
|
|
endPos++
|
|
}
|
|
p.consumeTokensForRuneRange(runePos, endPos)
|
|
|
|
return name, nil
|
|
}
|
|
|
|
func (p *Parser) parseUse(tok Token) (*UseNode, error) {
|
|
name, err := p.parseQuotedName(tok, "@use name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &UseNode{Pos: tok.Pos, Name: name}, nil
|
|
}
|
|
|
|
func (p *Parser) parseNamespace(tok Token, nodeCount int) (*NamespaceNode, error) {
|
|
if nodeCount > 0 {
|
|
return nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "@namespace must be at the top of the file")
|
|
}
|
|
|
|
name, err := p.parseQuotedName(tok, "@namespace name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 -= utf8.RuneCountInString(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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "unterminated %s block", 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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "@tpl blocks cannot be nested")
|
|
case TokIncludeStart:
|
|
subNodes, err := p.expandInclude(tok)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
body = append(body, subNodes...)
|
|
case TokUseStart:
|
|
node, err := p.parseUse(tok)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
body = append(body, node)
|
|
case TokNamespaceStart:
|
|
return nil, nil, nil, PosErrorf(tok.Pos.Line, tok.Pos.Col, "@namespace must be at file top level")
|
|
}
|
|
}
|
|
|
|
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, PosErrorf(tok.Pos.Line, tok.Pos.Col, "expected '{' after else")
|
|
}
|
|
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, PosErrorf(next.Pos.Line, next.Pos.Col, "expected 'if' in else-if condition")
|
|
}
|
|
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, PosErrorf(next.Pos.Line, next.Pos.Col, "expected '(' after 'else if'")
|
|
}
|
|
|
|
// Compute rune position of '(' in the raw input.
|
|
// The text token's Pos marks the END of the token text; the start is Pos - rune count.
|
|
runePos := runePosFromLineCol(p.input, next.Pos.Line, next.Pos.Col) - utf8.RuneCountInString(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, PosErrorf(next.Pos.Line, next.Pos.Col, "expected '(' after 'else if'")
|
|
}
|
|
|
|
// 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, PosErrorf(next.Pos.Line, next.Pos.Col, "unterminated else-if condition, expected ')'")
|
|
}
|
|
|
|
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, PosErrorf(next.Pos.Line, next.Pos.Col, "expected '{' after else-if condition")
|
|
}
|
|
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) == ""
|
|
}
|