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