A go template renderer based on Perl's Template Toolkit

fix: support function calls on dot-notation identifiers

- Allow explicit function calls on dot-notation (e.g., h.mytest())
- Auto-invoke bareword functions accessed via dot notation (e.g., h.mytest)
- Added resolveIdentNoAutoInvoke for resolving function references without auto-invocation
- Updated parser to parse function calls on the last part of dot-notation identifiers
- Added comprehensive tests for functions in maps and nested maps

+732 -569
+44 -20
eval.go
··· 522 522 // The function signature should be: func(args ...string) any 523 523 // or for no-arg functions: func() any 524 524 func (e *Evaluator) evalCall(c *CallExpr) (any, error) { 525 - fn, ok := e.vars[c.Func] 526 - if !ok { 525 + // Parse the function path (e.g., "h.mytest" -> ["h", "mytest"]) 526 + parts := strings.Split(c.Func, ".") 527 + 528 + // Resolve the function using dot notation without auto-invoking 529 + fn, err := e.resolveIdentNoAutoInvoke(parts) 530 + if err != nil { 531 + return nil, err 532 + } 533 + 534 + if fn == nil { 527 535 return nil, &EvalError{ 528 536 Pos: c.Position, 529 537 Message: fmt.Sprintf("undefined function: %s", c.Func), ··· 582 590 583 591 // resolveIdent resolves a dot-notation identifier 584 592 func (e *Evaluator) resolveIdent(parts []string) (any, error) { 593 + val, err := e.resolveIdentNoAutoInvoke(parts) 594 + if err != nil { 595 + return nil, err 596 + } 597 + 598 + if val == nil { 599 + return nil, nil 600 + } 601 + 602 + // Auto-invoke functions at the end of dot notation (for bareword syntax) 603 + fnValue := reflect.ValueOf(val) 604 + if fnValue.Kind() == reflect.Func { 605 + fnType := fnValue.Type() 606 + // Check if function takes no required arguments (0 args, or variadic that accepts 0) 607 + canCallWithNoArgs := fnType.NumIn() == 0 || 608 + (fnType.NumIn() == 1 && fnType.IsVariadic()) 609 + if canCallWithNoArgs { 610 + // Call the function with no arguments 611 + results := fnValue.Call([]reflect.Value{}) 612 + if len(results) > 0 { 613 + return results[0].Interface(), nil 614 + } 615 + return nil, nil 616 + } 617 + // Function requires arguments but called without parens - return nil 618 + return nil, nil 619 + } 620 + 621 + return val, nil 622 + } 623 + 624 + // resolveIdentNoAutoInvoke resolves a dot-notation identifier without auto-invoking functions 625 + // This is used when we need to resolve a function reference for calling 626 + func (e *Evaluator) resolveIdentNoAutoInvoke(parts []string) (any, error) { 585 627 if len(parts) == 0 { 586 628 return nil, nil 587 629 } ··· 607 649 } 608 650 609 651 if len(parts) == 1 { 610 - // Check if it's a function that can be called with no arguments 611 - fnValue := reflect.ValueOf(val) 612 - if fnValue.Kind() == reflect.Func { 613 - fnType := fnValue.Type() 614 - // Check if function takes no required arguments (0 args, or variadic that accepts 0) 615 - canCallWithNoArgs := fnType.NumIn() == 0 || 616 - (fnType.NumIn() == 1 && fnType.IsVariadic()) 617 - if canCallWithNoArgs { 618 - // Call the function with no arguments 619 - results := fnValue.Call([]reflect.Value{}) 620 - if len(results) > 0 { 621 - return results[0].Interface(), nil 622 - } 623 - return nil, nil 624 - } 625 - // Function requires arguments but called without parens - return nil 626 - return nil, nil 627 - } 628 652 return val, nil 629 653 } 630 654
+132
function_map_test.go
··· 1 + package gott 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestFunctionInMap(t *testing.T) { 8 + renderer, err := New(nil) 9 + if err != nil { 10 + t.Fatalf("Failed to create renderer: %v", err) 11 + } 12 + 13 + helpers := map[string]any{ 14 + "mytest": func() any { 15 + return "string-one" 16 + }, 17 + "withArgs": func(args ...string) any { 18 + return "args:" + args[0] 19 + }, 20 + "requiresArgs": func(arg string) string { 21 + return "got:" + arg 22 + }, 23 + } 24 + 25 + vars := map[string]any{ 26 + "h": helpers, 27 + } 28 + 29 + tests := []struct { 30 + name string 31 + template string 32 + want string 33 + }{ 34 + { 35 + name: "function in map - bareword", 36 + template: "[% h.mytest %]", 37 + want: "string-one", 38 + }, 39 + { 40 + name: "function in map - explicit call", 41 + template: "[% h.mytest() %]", 42 + want: "string-one", 43 + }, 44 + { 45 + name: "function with args - explicit call", 46 + template: "[% h.withArgs('foo') %]", 47 + want: "args:foo", 48 + }, 49 + { 50 + name: "function requiring args - bareword returns empty", 51 + template: "[% h.requiresArgs %]", 52 + want: "", 53 + }, 54 + { 55 + name: "function requiring args - explicit call", 56 + template: "[% h.requiresArgs('bar') %]", 57 + want: "got:bar", 58 + }, 59 + { 60 + name: "function in condition", 61 + template: "[% IF h.mytest == 'string-one' %]yes[% END %]", 62 + want: "yes", 63 + }, 64 + { 65 + name: "function in expression", 66 + template: "[% h.mytest %]-suffix", 67 + want: "string-one-suffix", 68 + }, 69 + } 70 + 71 + for _, tt := range tests { 72 + t.Run(tt.name, func(t *testing.T) { 73 + got, err := renderer.Process(tt.template, vars) 74 + if err != nil { 75 + t.Fatalf("Process error: %v", err) 76 + } 77 + if got != tt.want { 78 + t.Errorf("got %q, want %q", got, tt.want) 79 + } 80 + }) 81 + } 82 + } 83 + 84 + func TestFunctionInNestedMap(t *testing.T) { 85 + renderer, err := New(nil) 86 + if err != nil { 87 + t.Fatalf("Failed to create renderer: %v", err) 88 + } 89 + 90 + nested := map[string]any{ 91 + "nestedFunc": func() any { 92 + return "nested-value" 93 + }, 94 + } 95 + 96 + helpers := map[string]any{ 97 + "inner": nested, 98 + } 99 + 100 + vars := map[string]any{ 101 + "h": helpers, 102 + } 103 + 104 + tests := []struct { 105 + name string 106 + template string 107 + want string 108 + }{ 109 + { 110 + name: "nested function - bareword", 111 + template: "[% h.inner.nestedFunc %]", 112 + want: "nested-value", 113 + }, 114 + { 115 + name: "nested function - explicit call", 116 + template: "[% h.inner.nestedFunc() %]", 117 + want: "nested-value", 118 + }, 119 + } 120 + 121 + for _, tt := range tests { 122 + t.Run(tt.name, func(t *testing.T) { 123 + got, err := renderer.Process(tt.template, vars) 124 + if err != nil { 125 + t.Fatalf("Process error: %v", err) 126 + } 127 + if got != tt.want { 128 + t.Errorf("got %q, want %q", got, tt.want) 129 + } 130 + }) 131 + } 132 + }
+556 -549
parser.go
··· 1 1 package gott 2 2 3 3 import ( 4 - "strconv" 4 + "strconv" 5 + "strings" 5 6 ) 6 7 7 8 // Parser parses a token stream into an AST 8 9 type Parser struct { 9 - lexer *Lexer 10 - token Token // current token 11 - peekToken Token // lookahead token 12 - errors []error // accumulated parse errors 10 + lexer *Lexer 11 + token Token // current token 12 + peekToken Token // lookahead token 13 + errors []error // accumulated parse errors 13 14 } 14 15 15 16 // NewParser creates a new parser for the given input 16 17 func NewParser(input string) *Parser { 17 - p := &Parser{ 18 - lexer: NewLexer(input), 19 - } 20 - // Load first two tokens 21 - p.advance() 22 - p.advance() 23 - return p 18 + p := &Parser{ 19 + lexer: NewLexer(input), 20 + } 21 + // Load first two tokens 22 + p.advance() 23 + p.advance() 24 + return p 24 25 } 25 26 26 27 // advance moves to the next token 27 28 func (p *Parser) advance() { 28 - p.token = p.peekToken 29 - p.peekToken = p.lexer.NextToken() 29 + p.token = p.peekToken 30 + p.peekToken = p.lexer.NextToken() 30 31 } 31 32 32 33 // expect checks that the current token is of the expected type and advances 33 34 func (p *Parser) expect(t TokenType) bool { 34 - if p.token.Type != t { 35 - p.errorf("expected %s, got %s", t, p.token.Type) 36 - return false 37 - } 38 - p.advance() 39 - return true 35 + if p.token.Type != t { 36 + p.errorf("expected %s, got %s", t, p.token.Type) 37 + return false 38 + } 39 + p.advance() 40 + return true 40 41 } 41 42 42 43 // errorf records a parse error 43 44 func (p *Parser) errorf(format string, args ...any) { 44 - err := &ParseError{ 45 - Pos: p.token.Pos, 46 - Message: sprintf(format, args...), 47 - } 48 - p.errors = append(p.errors, err) 45 + err := &ParseError{ 46 + Pos: p.token.Pos, 47 + Message: sprintf(format, args...), 48 + } 49 + p.errors = append(p.errors, err) 49 50 } 50 51 51 52 // Parse parses the input and returns the AST 52 53 func (p *Parser) Parse() (*Template, []error) { 53 - t := &Template{ 54 - Position: Position{Line: 1, Column: 1, Offset: 0}, 55 - Nodes: []Node{}, 56 - } 54 + t := &Template{ 55 + Position: Position{Line: 1, Column: 1, Offset: 0}, 56 + Nodes: []Node{}, 57 + } 57 58 58 - for p.token.Type != TokenEOF && p.token.Type != TokenError { 59 - node := p.parseNode() 60 - if node != nil { 61 - t.Nodes = append(t.Nodes, node) 62 - } 63 - } 59 + for p.token.Type != TokenEOF && p.token.Type != TokenError { 60 + node := p.parseNode() 61 + if node != nil { 62 + t.Nodes = append(t.Nodes, node) 63 + } 64 + } 64 65 65 - if p.token.Type == TokenError { 66 - p.errors = append(p.errors, &ParseError{ 67 - Pos: p.token.Pos, 68 - Message: p.token.Value, 69 - }) 70 - } 66 + if p.token.Type == TokenError { 67 + p.errors = append(p.errors, &ParseError{ 68 + Pos: p.token.Pos, 69 + Message: p.token.Value, 70 + }) 71 + } 71 72 72 - return t, p.errors 73 + return t, p.errors 73 74 } 74 75 75 76 // parseNode parses a single node (text or tag) 76 77 func (p *Parser) parseNode() Node { 77 - switch p.token.Type { 78 - case TokenText: 79 - return p.parseText() 80 - case TokenTagOpen: 81 - return p.parseTag() 82 - default: 83 - p.errorf("unexpected token: %s", p.token.Type) 84 - p.advance() 85 - return nil 86 - } 78 + switch p.token.Type { 79 + case TokenText: 80 + return p.parseText() 81 + case TokenTagOpen: 82 + return p.parseTag() 83 + default: 84 + p.errorf("unexpected token: %s", p.token.Type) 85 + p.advance() 86 + return nil 87 + } 87 88 } 88 89 89 90 // parseText parses a text node 90 91 func (p *Parser) parseText() Node { 91 - node := &TextNode{ 92 - Position: p.token.Pos, 93 - Text: p.token.Value, 94 - } 95 - p.advance() 96 - return node 92 + node := &TextNode{ 93 + Position: p.token.Pos, 94 + Text: p.token.Value, 95 + } 96 + p.advance() 97 + return node 97 98 } 98 99 99 100 // parseTag parses a [% ... %] tag 100 101 func (p *Parser) parseTag() Node { 101 - p.expect(TokenTagOpen) 102 + p.expect(TokenTagOpen) 102 103 103 - switch p.token.Type { 104 - case TokenIF: 105 - return p.parseIf() 106 - case TokenUNLESS: 107 - return p.parseUnless() 108 - case TokenFOREACH: 109 - return p.parseForeach() 110 - case TokenBLOCK: 111 - return p.parseBlock() 112 - case TokenINCLUDE: 113 - return p.parseInclude() 114 - case TokenWRAPPER: 115 - return p.parseWrapper() 116 - case TokenSET: 117 - return p.parseSet() 118 - case TokenTRY: 119 - return p.parseTry() 120 - default: 121 - // Expression output: [% expr %] 122 - return p.parseOutput() 123 - } 104 + switch p.token.Type { 105 + case TokenIF: 106 + return p.parseIf() 107 + case TokenUNLESS: 108 + return p.parseUnless() 109 + case TokenFOREACH: 110 + return p.parseForeach() 111 + case TokenBLOCK: 112 + return p.parseBlock() 113 + case TokenINCLUDE: 114 + return p.parseInclude() 115 + case TokenWRAPPER: 116 + return p.parseWrapper() 117 + case TokenSET: 118 + return p.parseSet() 119 + case TokenTRY: 120 + return p.parseTry() 121 + default: 122 + // Expression output: [% expr %] 123 + return p.parseOutput() 124 + } 124 125 } 125 126 126 127 // parseIf parses an IF statement with optional ELSIF and ELSE 127 128 func (p *Parser) parseIf() *IfStmt { 128 - pos := p.token.Pos 129 - p.expect(TokenIF) 129 + pos := p.token.Pos 130 + p.expect(TokenIF) 130 131 131 - cond := p.parseExpr() 132 - if !p.expect(TokenTagClose) { 133 - return nil 134 - } 132 + cond := p.parseExpr() 133 + if !p.expect(TokenTagClose) { 134 + return nil 135 + } 135 136 136 - stmt := &IfStmt{ 137 - Position: pos, 138 - Condition: cond, 139 - } 137 + stmt := &IfStmt{ 138 + Position: pos, 139 + Condition: cond, 140 + } 140 141 141 - // Parse body until ELSIF, ELSE, or END 142 - stmt.Body = p.parseBody(TokenELSIF, TokenELSE, TokenEND) 142 + // Parse body until ELSIF, ELSE, or END 143 + stmt.Body = p.parseBody(TokenELSIF, TokenELSE, TokenEND) 143 144 144 - // Parse ELSIF chain 145 - for p.token.Type == TokenTagOpen && p.peekToken.Type == TokenELSIF { 146 - p.expect(TokenTagOpen) 147 - elsifPos := p.token.Pos 148 - p.expect(TokenELSIF) 145 + // Parse ELSIF chain 146 + for p.token.Type == TokenTagOpen && p.peekToken.Type == TokenELSIF { 147 + p.expect(TokenTagOpen) 148 + elsifPos := p.token.Pos 149 + p.expect(TokenELSIF) 149 150 150 - elsifCond := p.parseExpr() 151 - if !p.expect(TokenTagClose) { 152 - return nil 153 - } 151 + elsifCond := p.parseExpr() 152 + if !p.expect(TokenTagClose) { 153 + return nil 154 + } 154 155 155 - elsifBody := p.parseBody(TokenELSIF, TokenELSE, TokenEND) 156 + elsifBody := p.parseBody(TokenELSIF, TokenELSE, TokenEND) 156 157 157 - stmt.ElsIf = append(stmt.ElsIf, &ElsIfClause{ 158 - Position: elsifPos, 159 - Condition: elsifCond, 160 - Body: elsifBody, 161 - }) 162 - } 158 + stmt.ElsIf = append(stmt.ElsIf, &ElsIfClause{ 159 + Position: elsifPos, 160 + Condition: elsifCond, 161 + Body: elsifBody, 162 + }) 163 + } 163 164 164 - // Parse optional ELSE 165 - if p.token.Type == TokenTagOpen && p.peekToken.Type == TokenELSE { 166 - p.expect(TokenTagOpen) 167 - p.expect(TokenELSE) 168 - p.expect(TokenTagClose) 169 - stmt.Else = p.parseBody(TokenEND) 170 - } 165 + // Parse optional ELSE 166 + if p.token.Type == TokenTagOpen && p.peekToken.Type == TokenELSE { 167 + p.expect(TokenTagOpen) 168 + p.expect(TokenELSE) 169 + p.expect(TokenTagClose) 170 + stmt.Else = p.parseBody(TokenEND) 171 + } 171 172 172 - // Expect END 173 - p.expectEndTag() 173 + // Expect END 174 + p.expectEndTag() 174 175 175 - return stmt 176 + return stmt 176 177 } 177 178 178 179 // parseUnless parses an UNLESS statement 179 180 func (p *Parser) parseUnless() *UnlessStmt { 180 - pos := p.token.Pos 181 - p.expect(TokenUNLESS) 181 + pos := p.token.Pos 182 + p.expect(TokenUNLESS) 182 183 183 - cond := p.parseExpr() 184 - if !p.expect(TokenTagClose) { 185 - return nil 186 - } 184 + cond := p.parseExpr() 185 + if !p.expect(TokenTagClose) { 186 + return nil 187 + } 187 188 188 - stmt := &UnlessStmt{ 189 - Position: pos, 190 - Condition: cond, 191 - } 189 + stmt := &UnlessStmt{ 190 + Position: pos, 191 + Condition: cond, 192 + } 192 193 193 - stmt.Body = p.parseBody(TokenELSE, TokenEND) 194 + stmt.Body = p.parseBody(TokenELSE, TokenEND) 194 195 195 - // Parse optional ELSE 196 - if p.token.Type == TokenTagOpen && p.peekToken.Type == TokenELSE { 197 - p.expect(TokenTagOpen) 198 - p.expect(TokenELSE) 199 - p.expect(TokenTagClose) 200 - stmt.Else = p.parseBody(TokenEND) 201 - } 196 + // Parse optional ELSE 197 + if p.token.Type == TokenTagOpen && p.peekToken.Type == TokenELSE { 198 + p.expect(TokenTagOpen) 199 + p.expect(TokenELSE) 200 + p.expect(TokenTagClose) 201 + stmt.Else = p.parseBody(TokenEND) 202 + } 202 203 203 - p.expectEndTag() 204 + p.expectEndTag() 204 205 205 - return stmt 206 + return stmt 206 207 } 207 208 208 209 // parseForeach parses a FOREACH loop 209 210 func (p *Parser) parseForeach() *ForeachStmt { 210 - pos := p.token.Pos 211 - p.expect(TokenFOREACH) 211 + pos := p.token.Pos 212 + p.expect(TokenFOREACH) 212 213 213 - if p.token.Type != TokenIdent { 214 - p.errorf("expected identifier, got %s", p.token.Type) 215 - return nil 216 - } 217 - itemVar := p.token.Value 218 - p.advance() 214 + if p.token.Type != TokenIdent { 215 + p.errorf("expected identifier, got %s", p.token.Type) 216 + return nil 217 + } 218 + itemVar := p.token.Value 219 + p.advance() 219 220 220 - if !p.expect(TokenIN) { 221 - return nil 222 - } 221 + if !p.expect(TokenIN) { 222 + return nil 223 + } 223 224 224 - listExpr := p.parseExpr() 225 - if !p.expect(TokenTagClose) { 226 - return nil 227 - } 225 + listExpr := p.parseExpr() 226 + if !p.expect(TokenTagClose) { 227 + return nil 228 + } 228 229 229 - body := p.parseBody(TokenEND) 230 - p.expectEndTag() 230 + body := p.parseBody(TokenEND) 231 + p.expectEndTag() 231 232 232 - return &ForeachStmt{ 233 - Position: pos, 234 - ItemVar: itemVar, 235 - ListExpr: listExpr, 236 - Body: body, 237 - } 233 + return &ForeachStmt{ 234 + Position: pos, 235 + ItemVar: itemVar, 236 + ListExpr: listExpr, 237 + Body: body, 238 + } 238 239 } 239 240 240 241 // parseBlock parses a BLOCK definition 241 242 func (p *Parser) parseBlock() *BlockStmt { 242 - pos := p.token.Pos 243 - p.expect(TokenBLOCK) 243 + pos := p.token.Pos 244 + p.expect(TokenBLOCK) 244 245 245 - if p.token.Type != TokenIdent { 246 - p.errorf("expected block name, got %s", p.token.Type) 247 - return nil 248 - } 249 - name := p.token.Value 250 - p.advance() 246 + if p.token.Type != TokenIdent { 247 + p.errorf("expected block name, got %s", p.token.Type) 248 + return nil 249 + } 250 + name := p.token.Value 251 + p.advance() 251 252 252 - if !p.expect(TokenTagClose) { 253 - return nil 254 - } 253 + if !p.expect(TokenTagClose) { 254 + return nil 255 + } 255 256 256 - body := p.parseBody(TokenEND) 257 - p.expectEndTag() 257 + body := p.parseBody(TokenEND) 258 + p.expectEndTag() 258 259 259 - return &BlockStmt{ 260 - Position: pos, 261 - Name: name, 262 - Body: body, 263 - } 260 + return &BlockStmt{ 261 + Position: pos, 262 + Name: name, 263 + Body: body, 264 + } 264 265 } 265 266 266 267 // parseInclude parses an INCLUDE directive 267 268 // Supports both static paths and dynamic paths with $variable interpolation: 268 - // [% INCLUDE templates/header.html %] 269 - // [% INCLUDE templates/$category/page.html %] 269 + // 270 + // [% INCLUDE templates/header.html %] 271 + // [% INCLUDE templates/$category/page.html %] 270 272 func (p *Parser) parseInclude() *IncludeStmt { 271 - pos := p.token.Pos 272 - p.expect(TokenINCLUDE) 273 + pos := p.token.Pos 274 + p.expect(TokenINCLUDE) 273 275 274 - name, pathParts := p.parsePath() 276 + name, pathParts := p.parsePath() 275 277 276 - p.expect(TokenTagClose) 278 + p.expect(TokenTagClose) 277 279 278 - return &IncludeStmt{ 279 - Position: pos, 280 - Name: name, 281 - PathParts: pathParts, 282 - } 280 + return &IncludeStmt{ 281 + Position: pos, 282 + Name: name, 283 + PathParts: pathParts, 284 + } 283 285 } 284 286 285 287 // parsePath parses a path that may contain $variable interpolations. 286 288 // Returns (staticPath, nil) for static paths, or ("", pathParts) for dynamic paths. 287 289 func (p *Parser) parsePath() (string, []PathPart) { 288 - var pathParts []PathPart 289 - var staticPath string 290 - hasDynamic := false 290 + var pathParts []PathPart 291 + var staticPath string 292 + hasDynamic := false 291 293 292 - // Handle quoted string paths (which can still contain variables in our syntax) 293 - if p.token.Type == TokenString { 294 - // For now, string literals are static-only (could be extended) 295 - staticPath = p.token.Value 296 - p.advance() 297 - return staticPath, nil 298 - } 294 + // Handle quoted string paths (which can still contain variables in our syntax) 295 + if p.token.Type == TokenString { 296 + // For now, string literals are static-only (could be extended) 297 + staticPath = p.token.Value 298 + p.advance() 299 + return staticPath, nil 300 + } 299 301 300 - // Parse path components: identifiers, /, ., and $variables 301 - for { 302 - switch p.token.Type { 303 - case TokenIdent: 304 - // Literal path segment 305 - if hasDynamic { 306 - pathParts = append(pathParts, PathPart{ 307 - IsVariable: false, 308 - Value: p.token.Value, 309 - }) 310 - } else { 311 - staticPath += p.token.Value 312 - } 313 - p.advance() 302 + // Parse path components: identifiers, /, ., and $variables 303 + for { 304 + switch p.token.Type { 305 + case TokenIdent: 306 + // Literal path segment 307 + if hasDynamic { 308 + pathParts = append(pathParts, PathPart{ 309 + IsVariable: false, 310 + Value: p.token.Value, 311 + }) 312 + } else { 313 + staticPath += p.token.Value 314 + } 315 + p.advance() 314 316 315 - case TokenDiv: 316 - // Path separator / 317 - if hasDynamic { 318 - pathParts = append(pathParts, PathPart{ 319 - IsVariable: false, 320 - Value: "/", 321 - }) 322 - } else { 323 - staticPath += "/" 324 - } 325 - p.advance() 317 + case TokenDiv: 318 + // Path separator / 319 + if hasDynamic { 320 + pathParts = append(pathParts, PathPart{ 321 + IsVariable: false, 322 + Value: "/", 323 + }) 324 + } else { 325 + staticPath += "/" 326 + } 327 + p.advance() 326 328 327 - case TokenDot: 328 - // File extension separator . 329 - if hasDynamic { 330 - pathParts = append(pathParts, PathPart{ 331 - IsVariable: false, 332 - Value: ".", 333 - }) 334 - } else { 335 - staticPath += "." 336 - } 337 - p.advance() 329 + case TokenDot: 330 + // File extension separator . 331 + if hasDynamic { 332 + pathParts = append(pathParts, PathPart{ 333 + IsVariable: false, 334 + Value: ".", 335 + }) 336 + } else { 337 + staticPath += "." 338 + } 339 + p.advance() 338 340 339 - case TokenDollar: 340 - // Variable interpolation: $varname or $var.name 341 - hasDynamic = true 342 - p.advance() 341 + case TokenDollar: 342 + // Variable interpolation: $varname or $var.name 343 + hasDynamic = true 344 + p.advance() 343 345 344 - // Convert any accumulated static path to pathParts 345 - if staticPath != "" { 346 - pathParts = append(pathParts, PathPart{ 347 - IsVariable: false, 348 - Value: staticPath, 349 - }) 350 - staticPath = "" 351 - } 346 + // Convert any accumulated static path to pathParts 347 + if staticPath != "" { 348 + pathParts = append(pathParts, PathPart{ 349 + IsVariable: false, 350 + Value: staticPath, 351 + }) 352 + staticPath = "" 353 + } 352 354 353 - // Parse variable name with optional dot notation 354 - if p.token.Type != TokenIdent { 355 - p.errorf("expected variable name after $, got %s", p.token.Type) 356 - return "", pathParts 357 - } 355 + // Parse variable name with optional dot notation 356 + if p.token.Type != TokenIdent { 357 + p.errorf("expected variable name after $, got %s", p.token.Type) 358 + return "", pathParts 359 + } 358 360 359 - varParts := []string{p.token.Value} 360 - p.advance() 361 + varParts := []string{p.token.Value} 362 + p.advance() 361 363 362 - // Check for dot notation: $user.name.value 363 - for p.token.Type == TokenDot && p.peekToken.Type == TokenIdent { 364 - p.advance() // consume dot 365 - varParts = append(varParts, p.token.Value) 366 - p.advance() // consume ident 367 - } 364 + // Check for dot notation: $user.name.value 365 + for p.token.Type == TokenDot && p.peekToken.Type == TokenIdent { 366 + p.advance() // consume dot 367 + varParts = append(varParts, p.token.Value) 368 + p.advance() // consume ident 369 + } 368 370 369 - pathParts = append(pathParts, PathPart{ 370 - IsVariable: true, 371 - Parts: varParts, 372 - }) 371 + pathParts = append(pathParts, PathPart{ 372 + IsVariable: true, 373 + Parts: varParts, 374 + }) 373 375 374 - default: 375 - // End of path 376 - if hasDynamic { 377 - return "", pathParts 378 - } 379 - return staticPath, nil 380 - } 381 - } 376 + default: 377 + // End of path 378 + if hasDynamic { 379 + return "", pathParts 380 + } 381 + return staticPath, nil 382 + } 383 + } 382 384 } 383 385 384 386 // parseWrapper parses a WRAPPER directive 385 387 // Supports both static paths and dynamic paths with $variable interpolation: 386 - // [% WRAPPER layouts/main.html %]content[% END %] 387 - // [% WRAPPER layouts/$theme/main.html %]content[% END %] 388 + // 389 + // [% WRAPPER layouts/main.html %]content[% END %] 390 + // [% WRAPPER layouts/$theme/main.html %]content[% END %] 388 391 func (p *Parser) parseWrapper() *WrapperStmt { 389 - pos := p.token.Pos 390 - p.expect(TokenWRAPPER) 392 + pos := p.token.Pos 393 + p.expect(TokenWRAPPER) 391 394 392 - name, pathParts := p.parsePath() 395 + name, pathParts := p.parsePath() 393 396 394 - if !p.expect(TokenTagClose) { 395 - return nil 396 - } 397 + if !p.expect(TokenTagClose) { 398 + return nil 399 + } 397 400 398 - content := p.parseBody(TokenEND) 399 - p.expectEndTag() 401 + content := p.parseBody(TokenEND) 402 + p.expectEndTag() 400 403 401 - return &WrapperStmt{ 402 - Position: pos, 403 - Name: name, 404 - PathParts: pathParts, 405 - Content: content, 406 - } 404 + return &WrapperStmt{ 405 + Position: pos, 406 + Name: name, 407 + PathParts: pathParts, 408 + Content: content, 409 + } 407 410 } 408 411 409 412 // parseSet parses a SET directive 410 413 func (p *Parser) parseSet() *SetStmt { 411 - pos := p.token.Pos 412 - p.expect(TokenSET) 414 + pos := p.token.Pos 415 + p.expect(TokenSET) 413 416 414 - if p.token.Type != TokenIdent { 415 - p.errorf("expected variable name, got %s", p.token.Type) 416 - return nil 417 - } 418 - varName := p.token.Value 419 - p.advance() 417 + if p.token.Type != TokenIdent { 418 + p.errorf("expected variable name, got %s", p.token.Type) 419 + return nil 420 + } 421 + varName := p.token.Value 422 + p.advance() 420 423 421 - if !p.expect(TokenAssign) { 422 - return nil 423 - } 424 + if !p.expect(TokenAssign) { 425 + return nil 426 + } 424 427 425 - value := p.parseExpr() 426 - p.expect(TokenTagClose) 428 + value := p.parseExpr() 429 + p.expect(TokenTagClose) 427 430 428 - return &SetStmt{ 429 - Position: pos, 430 - Var: varName, 431 - Value: value, 432 - } 431 + return &SetStmt{ 432 + Position: pos, 433 + Var: varName, 434 + Value: value, 435 + } 433 436 } 434 437 435 438 // parseTry parses a TRY/CATCH block 436 439 func (p *Parser) parseTry() *TryStmt { 437 - pos := p.token.Pos 438 - p.expect(TokenTRY) 439 - p.expect(TokenTagClose) 440 + pos := p.token.Pos 441 + p.expect(TokenTRY) 442 + p.expect(TokenTagClose) 440 443 441 - stmt := &TryStmt{ 442 - Position: pos, 443 - } 444 + stmt := &TryStmt{ 445 + Position: pos, 446 + } 444 447 445 - // Parse try body until CATCH or END 446 - stmt.Try = p.parseBody(TokenCATCH, TokenEND) 448 + // Parse try body until CATCH or END 449 + stmt.Try = p.parseBody(TokenCATCH, TokenEND) 447 450 448 - // Parse optional CATCH 449 - if p.token.Type == TokenTagOpen && p.peekToken.Type == TokenCATCH { 450 - p.expect(TokenTagOpen) 451 - p.expect(TokenCATCH) 452 - p.expect(TokenTagClose) 453 - stmt.Catch = p.parseBody(TokenEND) 454 - } 451 + // Parse optional CATCH 452 + if p.token.Type == TokenTagOpen && p.peekToken.Type == TokenCATCH { 453 + p.expect(TokenTagOpen) 454 + p.expect(TokenCATCH) 455 + p.expect(TokenTagClose) 456 + stmt.Catch = p.parseBody(TokenEND) 457 + } 455 458 456 - // Expect END 457 - p.expectEndTag() 459 + // Expect END 460 + p.expectEndTag() 458 461 459 - return stmt 462 + return stmt 460 463 } 461 464 462 465 // parseOutput parses an expression output: [% expr %] 463 466 func (p *Parser) parseOutput() *OutputStmt { 464 - pos := p.token.Pos 465 - expr := p.parseExpr() 466 - p.expect(TokenTagClose) 467 + pos := p.token.Pos 468 + expr := p.parseExpr() 469 + p.expect(TokenTagClose) 467 470 468 - return &OutputStmt{ 469 - Position: pos, 470 - Expr: expr, 471 - } 471 + return &OutputStmt{ 472 + Position: pos, 473 + Expr: expr, 474 + } 472 475 } 473 476 474 477 // parseBody parses nodes until one of the stop tokens is seen as the next keyword 475 478 func (p *Parser) parseBody(stopTokens ...TokenType) []Node { 476 - var nodes []Node 479 + var nodes []Node 477 480 478 - for { 479 - // Check for EOF 480 - if p.token.Type == TokenEOF { 481 - break 482 - } 481 + for { 482 + // Check for EOF 483 + if p.token.Type == TokenEOF { 484 + break 485 + } 483 486 484 - // Check if next tag starts with a stop token 485 - if p.token.Type == TokenTagOpen { 486 - for _, stop := range stopTokens { 487 - if p.peekToken.Type == stop { 488 - return nodes 489 - } 490 - } 491 - } 487 + // Check if next tag starts with a stop token 488 + if p.token.Type == TokenTagOpen { 489 + for _, stop := range stopTokens { 490 + if p.peekToken.Type == stop { 491 + return nodes 492 + } 493 + } 494 + } 492 495 493 - node := p.parseNode() 494 - if node != nil { 495 - nodes = append(nodes, node) 496 - } 497 - } 496 + node := p.parseNode() 497 + if node != nil { 498 + nodes = append(nodes, node) 499 + } 500 + } 498 501 499 - return nodes 502 + return nodes 500 503 } 501 504 502 505 // expectEndTag expects [% END %] 503 506 func (p *Parser) expectEndTag() { 504 - if p.token.Type == TokenTagOpen && p.peekToken.Type == TokenEND { 505 - p.expect(TokenTagOpen) 506 - p.expect(TokenEND) 507 - p.expect(TokenTagClose) 508 - } else { 509 - p.errorf("expected [%% END %%], got %s", p.token.Type) 510 - } 507 + if p.token.Type == TokenTagOpen && p.peekToken.Type == TokenEND { 508 + p.expect(TokenTagOpen) 509 + p.expect(TokenEND) 510 + p.expect(TokenTagClose) 511 + } else { 512 + p.errorf("expected [%% END %%], got %s", p.token.Type) 513 + } 511 514 } 512 515 513 516 // ---- Expression Parsing (with precedence) ---- 514 517 515 518 // parseExpr is the entry point for expression parsing 516 519 func (p *Parser) parseExpr() Expr { 517 - return p.parseOr() 520 + return p.parseOr() 518 521 } 519 522 520 523 // parseOr handles || (logical OR) and || (default value) 521 524 // When || is followed by a literal and left is an identifier/filter expr, treat as default 522 525 func (p *Parser) parseOr() Expr { 523 - left := p.parseAnd() 526 + left := p.parseAnd() 524 527 525 - for p.token.Type == TokenOr { 526 - pos := p.token.Pos 527 - p.advance() 528 - right := p.parseAnd() 528 + for p.token.Type == TokenOr { 529 + pos := p.token.Pos 530 + p.advance() 531 + right := p.parseAnd() 529 532 530 - // Check if this looks like a default value expression: 531 - // left is identifier/filter, right is a literal 532 - if isDefaultCandidate(left) && isLiteralExpr(right) { 533 - left = &DefaultExpr{ 534 - Position: pos, 535 - Expr: left, 536 - Default: right, 537 - } 538 - } else { 539 - left = &BinaryExpr{ 540 - Position: pos, 541 - Op: TokenOr, 542 - Left: left, 543 - Right: right, 544 - } 545 - } 546 - } 533 + // Check if this looks like a default value expression: 534 + // left is identifier/filter, right is a literal 535 + if isDefaultCandidate(left) && isLiteralExpr(right) { 536 + left = &DefaultExpr{ 537 + Position: pos, 538 + Expr: left, 539 + Default: right, 540 + } 541 + } else { 542 + left = &BinaryExpr{ 543 + Position: pos, 544 + Op: TokenOr, 545 + Left: left, 546 + Right: right, 547 + } 548 + } 549 + } 547 550 548 - return left 551 + return left 549 552 } 550 553 551 554 // isDefaultCandidate returns true if the expression can have a default value 552 555 func isDefaultCandidate(e Expr) bool { 553 - switch e.(type) { 554 - case *IdentExpr, *FilterExpr: 555 - return true 556 - } 557 - return false 556 + switch e.(type) { 557 + case *IdentExpr, *FilterExpr: 558 + return true 559 + } 560 + return false 558 561 } 559 562 560 563 // isLiteralExpr returns true if the expression is a literal 561 564 func isLiteralExpr(e Expr) bool { 562 - _, ok := e.(*LiteralExpr) 563 - return ok 565 + _, ok := e.(*LiteralExpr) 566 + return ok 564 567 } 565 568 566 569 // parseAnd handles && (logical AND) 567 570 func (p *Parser) parseAnd() Expr { 568 - left := p.parseComparison() 571 + left := p.parseComparison() 569 572 570 - for p.token.Type == TokenAnd { 571 - op := p.token.Type 572 - pos := p.token.Pos 573 - p.advance() 574 - right := p.parseComparison() 575 - left = &BinaryExpr{ 576 - Position: pos, 577 - Op: op, 578 - Left: left, 579 - Right: right, 580 - } 581 - } 573 + for p.token.Type == TokenAnd { 574 + op := p.token.Type 575 + pos := p.token.Pos 576 + p.advance() 577 + right := p.parseComparison() 578 + left = &BinaryExpr{ 579 + Position: pos, 580 + Op: op, 581 + Left: left, 582 + Right: right, 583 + } 584 + } 582 585 583 - return left 586 + return left 584 587 } 585 588 586 589 // parseComparison handles ==, !=, <, <=, >, >= 587 590 func (p *Parser) parseComparison() Expr { 588 - left := p.parseAdditive() 591 + left := p.parseAdditive() 589 592 590 - if isComparisonOp(p.token.Type) { 591 - op := p.token.Type 592 - pos := p.token.Pos 593 - p.advance() 594 - right := p.parseAdditive() 595 - return &BinaryExpr{ 596 - Position: pos, 597 - Op: op, 598 - Left: left, 599 - Right: right, 600 - } 601 - } 593 + if isComparisonOp(p.token.Type) { 594 + op := p.token.Type 595 + pos := p.token.Pos 596 + p.advance() 597 + right := p.parseAdditive() 598 + return &BinaryExpr{ 599 + Position: pos, 600 + Op: op, 601 + Left: left, 602 + Right: right, 603 + } 604 + } 602 605 603 - return left 606 + return left 604 607 } 605 608 606 609 // parseAdditive handles + and - 607 610 func (p *Parser) parseAdditive() Expr { 608 - left := p.parseMultiplicative() 611 + left := p.parseMultiplicative() 609 612 610 - for p.token.Type == TokenPlus || p.token.Type == TokenMinus { 611 - op := p.token.Type 612 - pos := p.token.Pos 613 - p.advance() 614 - right := p.parseMultiplicative() 615 - left = &BinaryExpr{ 616 - Position: pos, 617 - Op: op, 618 - Left: left, 619 - Right: right, 620 - } 621 - } 613 + for p.token.Type == TokenPlus || p.token.Type == TokenMinus { 614 + op := p.token.Type 615 + pos := p.token.Pos 616 + p.advance() 617 + right := p.parseMultiplicative() 618 + left = &BinaryExpr{ 619 + Position: pos, 620 + Op: op, 621 + Left: left, 622 + Right: right, 623 + } 624 + } 622 625 623 - return left 626 + return left 624 627 } 625 628 626 629 // parseMultiplicative handles *, /, % 627 630 func (p *Parser) parseMultiplicative() Expr { 628 - left := p.parseUnary() 631 + left := p.parseUnary() 629 632 630 - for p.token.Type == TokenMul || p.token.Type == TokenDiv || p.token.Type == TokenMod { 631 - op := p.token.Type 632 - pos := p.token.Pos 633 - p.advance() 634 - right := p.parseUnary() 635 - left = &BinaryExpr{ 636 - Position: pos, 637 - Op: op, 638 - Left: left, 639 - Right: right, 640 - } 641 - } 633 + for p.token.Type == TokenMul || p.token.Type == TokenDiv || p.token.Type == TokenMod { 634 + op := p.token.Type 635 + pos := p.token.Pos 636 + p.advance() 637 + right := p.parseUnary() 638 + left = &BinaryExpr{ 639 + Position: pos, 640 + Op: op, 641 + Left: left, 642 + Right: right, 643 + } 644 + } 642 645 643 - return left 646 + return left 644 647 } 645 648 646 649 // parseUnary handles unary - (negation) 647 650 func (p *Parser) parseUnary() Expr { 648 - if p.token.Type == TokenMinus { 649 - pos := p.token.Pos 650 - p.advance() 651 - return &UnaryExpr{ 652 - Position: pos, 653 - Op: TokenMinus, 654 - X: p.parseUnary(), 655 - } 656 - } 657 - return p.parsePrimary() 651 + if p.token.Type == TokenMinus { 652 + pos := p.token.Pos 653 + p.advance() 654 + return &UnaryExpr{ 655 + Position: pos, 656 + Op: TokenMinus, 657 + X: p.parseUnary(), 658 + } 659 + } 660 + return p.parsePrimary() 658 661 } 659 662 660 663 // parsePrimary handles literals, identifiers, function calls, and parentheses 661 664 func (p *Parser) parsePrimary() Expr { 662 - switch p.token.Type { 663 - case TokenNumber: 664 - val, _ := strconv.ParseFloat(p.token.Value, 64) 665 - expr := &LiteralExpr{ 666 - Position: p.token.Pos, 667 - Value: val, 668 - } 669 - p.advance() 670 - return expr 665 + switch p.token.Type { 666 + case TokenNumber: 667 + val, _ := strconv.ParseFloat(p.token.Value, 64) 668 + expr := &LiteralExpr{ 669 + Position: p.token.Pos, 670 + Value: val, 671 + } 672 + p.advance() 673 + return expr 671 674 672 - case TokenString: 673 - expr := &LiteralExpr{ 674 - Position: p.token.Pos, 675 - Value: p.token.Value, 676 - } 677 - p.advance() 678 - return expr 675 + case TokenString: 676 + expr := &LiteralExpr{ 677 + Position: p.token.Pos, 678 + Value: p.token.Value, 679 + } 680 + p.advance() 681 + return expr 679 682 680 - case TokenIdent: 681 - return p.parseIdentOrCall() 683 + case TokenIdent: 684 + return p.parseIdentOrCall() 682 685 683 - case TokenLParen: 684 - p.advance() 685 - expr := p.parseExpr() 686 - p.expect(TokenRParen) 687 - return expr 688 - } 686 + case TokenLParen: 687 + p.advance() 688 + expr := p.parseExpr() 689 + p.expect(TokenRParen) 690 + return expr 691 + } 689 692 690 - p.errorf("unexpected token in expression: %s", p.token.Type) 691 - return &LiteralExpr{Position: p.token.Pos, Value: ""} 693 + p.errorf("unexpected token in expression: %s", p.token.Type) 694 + return &LiteralExpr{Position: p.token.Pos, Value: ""} 692 695 } 693 696 694 697 // parseIdentOrCall parses an identifier, possibly with dots, function calls, or filters 695 698 func (p *Parser) parseIdentOrCall() Expr { 696 - pos := p.token.Pos 699 + pos := p.token.Pos 697 700 698 - // Collect dot-separated parts: foo.bar.baz 699 - parts := []string{p.token.Value} 700 - p.advance() 701 + // Collect dot-separated parts: foo.bar.baz 702 + parts := []string{p.token.Value} 703 + p.advance() 701 704 702 - for p.token.Type == TokenDot { 703 - p.advance() 704 - if p.token.Type != TokenIdent { 705 - p.errorf("expected identifier after '.', got %s", p.token.Type) 706 - break 707 - } 708 - parts = append(parts, p.token.Value) 709 - p.advance() 710 - } 705 + for p.token.Type == TokenDot { 706 + p.advance() 707 + if p.token.Type != TokenIdent { 708 + p.errorf("expected identifier after '.', got %s", p.token.Type) 709 + break 710 + } 711 + parts = append(parts, p.token.Value) 712 + p.advance() 713 + } 711 714 712 - var expr Expr = &IdentExpr{ 713 - Position: pos, 714 - Parts: parts, 715 - } 715 + var expr Expr = &IdentExpr{ 716 + Position: pos, 717 + Parts: parts, 718 + } 716 719 717 - // Check for function call: func(args) 718 - if p.token.Type == TokenLParen && len(parts) == 1 { 719 - p.advance() 720 - args := p.parseArgList() 721 - p.expect(TokenRParen) 722 - expr = &CallExpr{ 723 - Position: pos, 724 - Func: parts[0], 725 - Args: args, 726 - } 727 - } 720 + // Check for function call: func(args) or obj.method(args) 721 + if p.token.Type == TokenLParen { 722 + p.advance() 723 + args := p.parseArgList() 724 + p.expect(TokenRParen) 728 725 729 - // Check for filter chain: expr | filter | filter(args) 730 - for p.token.Type == TokenPipe { 731 - p.advance() 732 - if p.token.Type != TokenIdent { 733 - p.errorf("expected filter name after '|', got %s", p.token.Type) 734 - break 735 - } 736 - filterName := p.token.Value 737 - filterPos := p.token.Pos 738 - p.advance() 726 + // Build function path: for parts = ["h", "mytest"], funcPath = "h.mytest" 727 + funcPath := strings.Join(parts, ".") 739 728 740 - var filterArgs []Expr 741 - if p.token.Type == TokenLParen { 742 - p.advance() 743 - filterArgs = p.parseArgList() 744 - p.expect(TokenRParen) 745 - } 729 + expr = &CallExpr{ 730 + Position: pos, 731 + Func: funcPath, 732 + Args: args, 733 + } 734 + } 746 735 747 - expr = &FilterExpr{ 748 - Position: filterPos, 749 - Input: expr, 750 - Filter: filterName, 751 - Args: filterArgs, 752 - } 753 - } 736 + // Check for filter chain: expr | filter | filter(args) 737 + for p.token.Type == TokenPipe { 738 + p.advance() 739 + if p.token.Type != TokenIdent { 740 + p.errorf("expected filter name after '|', got %s", p.token.Type) 741 + break 742 + } 743 + filterName := p.token.Value 744 + filterPos := p.token.Pos 745 + p.advance() 746 + 747 + var filterArgs []Expr 748 + if p.token.Type == TokenLParen { 749 + p.advance() 750 + filterArgs = p.parseArgList() 751 + p.expect(TokenRParen) 752 + } 753 + 754 + expr = &FilterExpr{ 755 + Position: filterPos, 756 + Input: expr, 757 + Filter: filterName, 758 + Args: filterArgs, 759 + } 760 + } 754 761 755 - return expr 762 + return expr 756 763 } 757 764 758 765 // parseArgList parses a comma-separated list of expressions 759 766 func (p *Parser) parseArgList() []Expr { 760 - var args []Expr 767 + var args []Expr 761 768 762 - if p.token.Type == TokenRParen { 763 - return args 764 - } 769 + if p.token.Type == TokenRParen { 770 + return args 771 + } 765 772 766 - args = append(args, p.parseExpr()) 773 + args = append(args, p.parseExpr()) 767 774 768 - for p.token.Type == TokenComma { 769 - p.advance() 770 - args = append(args, p.parseExpr()) 771 - } 775 + for p.token.Type == TokenComma { 776 + p.advance() 777 + args = append(args, p.parseExpr()) 778 + } 772 779 773 - return args 780 + return args 774 781 } 775 782 776 783 // isComparisonOp returns true if the token is a comparison operator 777 784 func isComparisonOp(t TokenType) bool { 778 - switch t { 779 - case TokenEq, TokenNe, TokenLt, TokenLe, TokenGt, TokenGe: 780 - return true 781 - } 782 - return false 785 + switch t { 786 + case TokenEq, TokenNe, TokenLt, TokenLe, TokenGt, TokenGe: 787 + return true 788 + } 789 + return false 783 790 }