mirror of
https://github.com/semihalev/twig.git
synced 2026-03-14 13:55:46 +01:00
Initial Twig template engine implementation
This commit is contained in:
commit
647dcbe96b
12 changed files with 2672 additions and 0 deletions
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 semihalev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
83
README.md
Normal file
83
README.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Twig
|
||||
|
||||
Twig is a fast, memory-efficient Twig template engine implementation for Go. It aims to provide full support for the Twig template language in a Go-native way.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero-allocation rendering where possible
|
||||
- Full Twig syntax support
|
||||
- Template inheritance
|
||||
- Extensible with filters, functions, tests, and operators
|
||||
- Multiple loader types (filesystem, in-memory)
|
||||
- Compatible with Go's standard library interfaces
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/semihalev/twig
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/semihalev/twig"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a new Twig engine
|
||||
engine := twig.New()
|
||||
|
||||
// Add a template loader
|
||||
loader := twig.NewFileSystemLoader([]string{"./templates"})
|
||||
engine.RegisterLoader(loader)
|
||||
|
||||
// Render a template
|
||||
context := map[string]interface{}{
|
||||
"name": "World",
|
||||
"items": []string{"apple", "banana", "orange"},
|
||||
}
|
||||
|
||||
// Render to a string
|
||||
result, err := engine.Render("index.twig", context)
|
||||
if err != nil {
|
||||
fmt.Println("Error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(result)
|
||||
|
||||
// Or render directly to a writer
|
||||
err = engine.RenderTo(os.Stdout, "index.twig", context)
|
||||
if err != nil {
|
||||
fmt.Println("Error:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Twig Syntax
|
||||
|
||||
- Variable printing: `{{ variable }}`
|
||||
- Control structures: `{% if %}`, `{% for %}`, etc.
|
||||
- Filters: `{{ variable|filter }}`
|
||||
- Functions: `{{ function(args) }}`
|
||||
- Template inheritance: `{% extends %}`, `{% block %}`
|
||||
- Includes: `{% include %}`
|
||||
- Comments: `{# comment #}`
|
||||
- And more...
|
||||
|
||||
## Performance
|
||||
|
||||
The library is designed with performance in mind:
|
||||
- Minimal memory allocations
|
||||
- Efficient parsing and rendering
|
||||
- Template caching
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
62
examples/simple/main.go
Normal file
62
examples/simple/main.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/semihalev/twig"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a new Twig engine
|
||||
engine := twig.New()
|
||||
|
||||
// Create an in-memory template loader
|
||||
templates := map[string]string{
|
||||
"hello": "Hello, {{ name }}!",
|
||||
"page": `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ title }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ title }}</h1>
|
||||
<ul>
|
||||
{% for item in items %}
|
||||
<li>{{ item }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</body>
|
||||
</html>`,
|
||||
}
|
||||
loader := twig.NewArrayLoader(templates)
|
||||
engine.RegisterLoader(loader)
|
||||
|
||||
// Render a simple template
|
||||
context := map[string]interface{}{
|
||||
"name": "World",
|
||||
}
|
||||
|
||||
result, err := engine.Render("hello", context)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering hello template: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Result of 'hello' template:")
|
||||
fmt.Println(result)
|
||||
fmt.Println()
|
||||
|
||||
// Render a more complex template
|
||||
pageContext := map[string]interface{}{
|
||||
"title": "My Page",
|
||||
"items": []string{"Item 1", "Item 2", "Item 3"},
|
||||
}
|
||||
|
||||
fmt.Println("Result of 'page' template:")
|
||||
err = engine.RenderTo(os.Stdout, "page", pageContext)
|
||||
if err != nil {
|
||||
fmt.Printf("Error rendering page template: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
255
expr.go
Normal file
255
expr.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ExpressionType represents the type of an expression
|
||||
type ExpressionType int
|
||||
|
||||
// Expression types
|
||||
const (
|
||||
ExprLiteral ExpressionType = iota
|
||||
ExprVariable
|
||||
ExprUnary
|
||||
ExprBinary
|
||||
ExprFunction
|
||||
ExprFilter
|
||||
ExprTest
|
||||
ExprGetAttr
|
||||
ExprGetItem
|
||||
ExprMethodCall
|
||||
ExprArray
|
||||
ExprHash
|
||||
ExprConditional
|
||||
)
|
||||
|
||||
// ExpressionNode represents a Twig expression
|
||||
type ExpressionNode struct {
|
||||
exprType ExpressionType
|
||||
line int
|
||||
}
|
||||
|
||||
// LiteralNode represents a literal value (string, number, boolean, null)
|
||||
type LiteralNode struct {
|
||||
ExpressionNode
|
||||
value interface{}
|
||||
}
|
||||
|
||||
// VariableNode represents a variable reference
|
||||
type VariableNode struct {
|
||||
ExpressionNode
|
||||
name string
|
||||
}
|
||||
|
||||
// UnaryNode represents a unary operation (not, -, +)
|
||||
type UnaryNode struct {
|
||||
ExpressionNode
|
||||
operator string
|
||||
node Node
|
||||
}
|
||||
|
||||
// BinaryNode represents a binary operation (+, -, *, /, etc)
|
||||
type BinaryNode struct {
|
||||
ExpressionNode
|
||||
operator string
|
||||
left Node
|
||||
right Node
|
||||
}
|
||||
|
||||
// FunctionNode represents a function call
|
||||
type FunctionNode struct {
|
||||
ExpressionNode
|
||||
name string
|
||||
args []Node
|
||||
}
|
||||
|
||||
// FilterNode represents a filter application
|
||||
type FilterNode struct {
|
||||
ExpressionNode
|
||||
node Node
|
||||
filter string
|
||||
args []Node
|
||||
}
|
||||
|
||||
// TestNode represents a test (is defined, is null, etc)
|
||||
type TestNode struct {
|
||||
ExpressionNode
|
||||
node Node
|
||||
test string
|
||||
args []Node
|
||||
}
|
||||
|
||||
// GetAttrNode represents attribute access (obj.attr)
|
||||
type GetAttrNode struct {
|
||||
ExpressionNode
|
||||
node Node
|
||||
attribute Node
|
||||
}
|
||||
|
||||
// GetItemNode represents item access (array[key])
|
||||
type GetItemNode struct {
|
||||
ExpressionNode
|
||||
node Node
|
||||
item Node
|
||||
}
|
||||
|
||||
// MethodCallNode represents method call (obj.method())
|
||||
type MethodCallNode struct {
|
||||
ExpressionNode
|
||||
node Node
|
||||
method string
|
||||
args []Node
|
||||
}
|
||||
|
||||
// ArrayNode represents an array literal
|
||||
type ArrayNode struct {
|
||||
ExpressionNode
|
||||
items []Node
|
||||
}
|
||||
|
||||
// HashNode represents a hash/map literal
|
||||
type HashNode struct {
|
||||
ExpressionNode
|
||||
items map[Node]Node
|
||||
}
|
||||
|
||||
// ConditionalNode represents ternary operator (condition ? true : false)
|
||||
type ConditionalNode struct {
|
||||
ExpressionNode
|
||||
condition Node
|
||||
trueExpr Node
|
||||
falseExpr Node
|
||||
}
|
||||
|
||||
// Type implementation for ExpressionNode
|
||||
func (n *ExpressionNode) Type() NodeType {
|
||||
return NodeExpression
|
||||
}
|
||||
|
||||
func (n *ExpressionNode) Line() int {
|
||||
return n.line
|
||||
}
|
||||
|
||||
// Render implementation for LiteralNode
|
||||
func (n *LiteralNode) Render(w io.Writer, ctx *RenderContext) error {
|
||||
var str string
|
||||
|
||||
switch v := n.value.(type) {
|
||||
case string:
|
||||
str = v
|
||||
case int:
|
||||
str = strconv.Itoa(v)
|
||||
case float64:
|
||||
str = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
str = strconv.FormatBool(v)
|
||||
case nil:
|
||||
str = ""
|
||||
default:
|
||||
str = ctx.ToString(v)
|
||||
}
|
||||
|
||||
_, err := w.Write([]byte(str))
|
||||
return err
|
||||
}
|
||||
|
||||
// NewLiteralNode creates a new literal node
|
||||
func NewLiteralNode(value interface{}, line int) *LiteralNode {
|
||||
return &LiteralNode{
|
||||
ExpressionNode: ExpressionNode{
|
||||
exprType: ExprLiteral,
|
||||
line: line,
|
||||
},
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
|
||||
// NewVariableNode creates a new variable node
|
||||
func NewVariableNode(name string, line int) *VariableNode {
|
||||
return &VariableNode{
|
||||
ExpressionNode: ExpressionNode{
|
||||
exprType: ExprVariable,
|
||||
line: line,
|
||||
},
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// NewBinaryNode creates a new binary operation node
|
||||
func NewBinaryNode(operator string, left, right Node, line int) *BinaryNode {
|
||||
return &BinaryNode{
|
||||
ExpressionNode: ExpressionNode{
|
||||
exprType: ExprBinary,
|
||||
line: line,
|
||||
},
|
||||
operator: operator,
|
||||
left: left,
|
||||
right: right,
|
||||
}
|
||||
}
|
||||
|
||||
// NewGetAttrNode creates a new attribute access node
|
||||
func NewGetAttrNode(node, attribute Node, line int) *GetAttrNode {
|
||||
return &GetAttrNode{
|
||||
ExpressionNode: ExpressionNode{
|
||||
exprType: ExprGetAttr,
|
||||
line: line,
|
||||
},
|
||||
node: node,
|
||||
attribute: attribute,
|
||||
}
|
||||
}
|
||||
|
||||
// Render implementation for VariableNode
|
||||
func (n *VariableNode) Render(w io.Writer, ctx *RenderContext) error {
|
||||
value, err := ctx.GetVariable(n.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
str := ctx.ToString(value)
|
||||
_, err = w.Write([]byte(str))
|
||||
return err
|
||||
}
|
||||
|
||||
// Render implementation for GetAttrNode
|
||||
func (n *GetAttrNode) Render(w io.Writer, ctx *RenderContext) error {
|
||||
obj, err := ctx.EvaluateExpression(n.node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attrName, err := ctx.EvaluateExpression(n.attribute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attrStr, ok := attrName.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("attribute name must be a string")
|
||||
}
|
||||
|
||||
value, err := ctx.getAttribute(obj, attrStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
str := ctx.ToString(value)
|
||||
_, err = w.Write([]byte(str))
|
||||
return err
|
||||
}
|
||||
|
||||
// Render implementation for BinaryNode
|
||||
func (n *BinaryNode) Render(w io.Writer, ctx *RenderContext) error {
|
||||
result, err := ctx.EvaluateExpression(n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
str := ctx.ToString(result)
|
||||
_, err = w.Write([]byte(str))
|
||||
return err
|
||||
}
|
||||
646
extension.go
Normal file
646
extension.go
Normal file
|
|
@ -0,0 +1,646 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FilterFunc is a function that can be used as a filter
|
||||
type FilterFunc func(value interface{}, args ...interface{}) (interface{}, error)
|
||||
|
||||
// FunctionFunc is a function that can be used in templates
|
||||
type FunctionFunc func(args ...interface{}) (interface{}, error)
|
||||
|
||||
// TestFunc is a function that can be used for testing conditions
|
||||
type TestFunc func(value interface{}, args ...interface{}) (bool, error)
|
||||
|
||||
// OperatorFunc is a function that implements a custom operator
|
||||
type OperatorFunc func(left, right interface{}) (interface{}, error)
|
||||
|
||||
// Extension represents a Twig extension
|
||||
type Extension interface {
|
||||
// GetName returns the name of the extension
|
||||
GetName() string
|
||||
|
||||
// GetFilters returns the filters defined by this extension
|
||||
GetFilters() map[string]FilterFunc
|
||||
|
||||
// GetFunctions returns the functions defined by this extension
|
||||
GetFunctions() map[string]FunctionFunc
|
||||
|
||||
// GetTests returns the tests defined by this extension
|
||||
GetTests() map[string]TestFunc
|
||||
|
||||
// GetOperators returns the operators defined by this extension
|
||||
GetOperators() map[string]OperatorFunc
|
||||
|
||||
// GetTokenParsers returns any custom token parsers
|
||||
GetTokenParsers() []TokenParser
|
||||
|
||||
// Initialize initializes the extension
|
||||
Initialize(*Engine)
|
||||
}
|
||||
|
||||
// TokenParser provides a way to parse custom tags
|
||||
type TokenParser interface {
|
||||
// GetTag returns the tag this parser handles
|
||||
GetTag() string
|
||||
|
||||
// Parse parses the tag and returns a node
|
||||
Parse(*Parser, *Token) (Node, error)
|
||||
}
|
||||
|
||||
// CoreExtension provides the core Twig functionality
|
||||
type CoreExtension struct{}
|
||||
|
||||
// GetName returns the name of the core extension
|
||||
func (e *CoreExtension) GetName() string {
|
||||
return "core"
|
||||
}
|
||||
|
||||
// GetFilters returns the core filters
|
||||
func (e *CoreExtension) GetFilters() map[string]FilterFunc {
|
||||
return map[string]FilterFunc{
|
||||
"default": e.filterDefault,
|
||||
"escape": e.filterEscape,
|
||||
"upper": e.filterUpper,
|
||||
"lower": e.filterLower,
|
||||
"trim": e.filterTrim,
|
||||
"raw": e.filterRaw,
|
||||
"length": e.filterLength,
|
||||
"join": e.filterJoin,
|
||||
"split": e.filterSplit,
|
||||
"date": e.filterDate,
|
||||
"url_encode": e.filterUrlEncode,
|
||||
}
|
||||
}
|
||||
|
||||
// GetFunctions returns the core functions
|
||||
func (e *CoreExtension) GetFunctions() map[string]FunctionFunc {
|
||||
return map[string]FunctionFunc{
|
||||
"range": e.functionRange,
|
||||
"date": e.functionDate,
|
||||
"random": e.functionRandom,
|
||||
"max": e.functionMax,
|
||||
"min": e.functionMin,
|
||||
"dump": e.functionDump,
|
||||
"constant": e.functionConstant,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTests returns the core tests
|
||||
func (e *CoreExtension) GetTests() map[string]TestFunc {
|
||||
return map[string]TestFunc{
|
||||
"defined": e.testDefined,
|
||||
"empty": e.testEmpty,
|
||||
"null": e.testNull,
|
||||
"even": e.testEven,
|
||||
"odd": e.testOdd,
|
||||
"iterable": e.testIterable,
|
||||
"same_as": e.testSameAs,
|
||||
"divisible_by": e.testDivisibleBy,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOperators returns the core operators
|
||||
func (e *CoreExtension) GetOperators() map[string]OperatorFunc {
|
||||
return map[string]OperatorFunc{
|
||||
"in": e.operatorIn,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTokenParsers returns the core token parsers
|
||||
func (e *CoreExtension) GetTokenParsers() []TokenParser {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize initializes the core extension
|
||||
func (e *CoreExtension) Initialize(engine *Engine) {
|
||||
// Nothing to initialize for core extension
|
||||
}
|
||||
|
||||
// Filter implementations
|
||||
|
||||
func (e *CoreExtension) filterDefault(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
if isEmptyValue(value) && len(args) > 0 {
|
||||
return args[0], nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterEscape(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
s := toString(value)
|
||||
return escapeHTML(s), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterUpper(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
s := toString(value)
|
||||
return strings.ToUpper(s), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterLower(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
s := toString(value)
|
||||
return strings.ToLower(s), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterTrim(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
s := toString(value)
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterRaw(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
// Raw just returns the value without any processing
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterLength(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
return length(value)
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterJoin(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
delimiter := " "
|
||||
if len(args) > 0 {
|
||||
if d, ok := args[0].(string); ok {
|
||||
delimiter = d
|
||||
}
|
||||
}
|
||||
|
||||
return join(value, delimiter)
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterSplit(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
delimiter := " "
|
||||
if len(args) > 0 {
|
||||
if d, ok := args[0].(string); ok {
|
||||
delimiter = d
|
||||
}
|
||||
}
|
||||
|
||||
s := toString(value)
|
||||
return strings.Split(s, delimiter), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterDate(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
// Implement date formatting
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) filterUrlEncode(value interface{}, args ...interface{}) (interface{}, error) {
|
||||
s := toString(value)
|
||||
return url.QueryEscape(s), nil
|
||||
}
|
||||
|
||||
// Function implementations
|
||||
|
||||
func (e *CoreExtension) functionRange(args ...interface{}) (interface{}, error) {
|
||||
if len(args) < 2 {
|
||||
return nil, errors.New("range function requires at least 2 arguments")
|
||||
}
|
||||
|
||||
start, err := toInt(args[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
end, err := toInt(args[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
step := 1
|
||||
if len(args) > 2 {
|
||||
s, err := toInt(args[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
step = s
|
||||
}
|
||||
|
||||
if step == 0 {
|
||||
return nil, errors.New("step cannot be zero")
|
||||
}
|
||||
|
||||
var result []int
|
||||
if step > 0 {
|
||||
for i := start; i <= end; i += step {
|
||||
result = append(result, i)
|
||||
}
|
||||
} else {
|
||||
for i := start; i >= end; i += step {
|
||||
result = append(result, i)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) functionDate(args ...interface{}) (interface{}, error) {
|
||||
// Implement date function
|
||||
return time.Now(), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) functionRandom(args ...interface{}) (interface{}, error) {
|
||||
// Implement random function
|
||||
return rand.Intn(100), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) functionMax(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("max function requires at least one argument")
|
||||
}
|
||||
|
||||
var max float64
|
||||
var initialized bool
|
||||
|
||||
for i, arg := range args {
|
||||
num, err := toFloat64(arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("argument %d is not a number", i)
|
||||
}
|
||||
|
||||
if !initialized || num > max {
|
||||
max = num
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
return max, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) functionMin(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("min function requires at least one argument")
|
||||
}
|
||||
|
||||
var min float64
|
||||
var initialized bool
|
||||
|
||||
for i, arg := range args {
|
||||
num, err := toFloat64(arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("argument %d is not a number", i)
|
||||
}
|
||||
|
||||
if !initialized || num < min {
|
||||
min = num
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
return min, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) functionDump(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for i, arg := range args {
|
||||
if i > 0 {
|
||||
result.WriteString(", ")
|
||||
}
|
||||
result.WriteString(fmt.Sprintf("%#v", arg))
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) functionConstant(args ...interface{}) (interface{}, error) {
|
||||
// Not applicable in Go, but included for compatibility
|
||||
return nil, errors.New("constant function not supported in Go")
|
||||
}
|
||||
|
||||
// Test implementations
|
||||
|
||||
func (e *CoreExtension) testDefined(value interface{}, args ...interface{}) (bool, error) {
|
||||
return value != nil, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) testEmpty(value interface{}, args ...interface{}) (bool, error) {
|
||||
return isEmptyValue(value), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) testNull(value interface{}, args ...interface{}) (bool, error) {
|
||||
return value == nil, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) testEven(value interface{}, args ...interface{}) (bool, error) {
|
||||
i, err := toInt(value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return i%2 == 0, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) testOdd(value interface{}, args ...interface{}) (bool, error) {
|
||||
i, err := toInt(value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return i%2 != 0, nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) testIterable(value interface{}, args ...interface{}) (bool, error) {
|
||||
return isIterable(value), nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) testSameAs(value interface{}, args ...interface{}) (bool, error) {
|
||||
if len(args) == 0 {
|
||||
return false, errors.New("same_as test requires an argument")
|
||||
}
|
||||
return value == args[0], nil
|
||||
}
|
||||
|
||||
func (e *CoreExtension) testDivisibleBy(value interface{}, args ...interface{}) (bool, error) {
|
||||
if len(args) == 0 {
|
||||
return false, errors.New("divisible_by test requires a divisor argument")
|
||||
}
|
||||
|
||||
dividend, err := toInt(value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
divisor, err := toInt(args[0])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if divisor == 0 {
|
||||
return false, errors.New("division by zero")
|
||||
}
|
||||
|
||||
return dividend%divisor == 0, nil
|
||||
}
|
||||
|
||||
// Operator implementations
|
||||
|
||||
func (e *CoreExtension) operatorIn(left, right interface{}) (interface{}, error) {
|
||||
if !isIterable(right) {
|
||||
return false, errors.New("right operand must be iterable")
|
||||
}
|
||||
|
||||
return contains(right, left)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func isEmptyValue(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return value == ""
|
||||
case bool:
|
||||
return !value
|
||||
case int, int8, int16, int32, int64:
|
||||
return value == 0
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
return value == 0
|
||||
case float32, float64:
|
||||
return value == 0
|
||||
case []interface{}:
|
||||
return len(value) == 0
|
||||
case map[string]interface{}:
|
||||
return len(value) == 0
|
||||
}
|
||||
|
||||
// Use reflection for other types
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Array, reflect.Slice, reflect.Map:
|
||||
return rv.Len() == 0
|
||||
case reflect.Bool:
|
||||
return !rv.Bool()
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return rv.Int() == 0
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return rv.Uint() == 0
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return rv.Float() == 0
|
||||
case reflect.String:
|
||||
return rv.String() == ""
|
||||
}
|
||||
|
||||
// Default behavior for other types
|
||||
return false
|
||||
}
|
||||
|
||||
func isIterable(v interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch v.(type) {
|
||||
case string, []interface{}, map[string]interface{}:
|
||||
return true
|
||||
}
|
||||
|
||||
// Use reflection for other types
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Array, reflect.Slice, reflect.Map, reflect.String:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func length(v interface{}) (int, error) {
|
||||
if v == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return len(value), nil
|
||||
case []interface{}:
|
||||
return len(value), nil
|
||||
case map[string]interface{}:
|
||||
return len(value), nil
|
||||
}
|
||||
|
||||
// Use reflection for other types
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Array, reflect.Slice, reflect.Map, reflect.String:
|
||||
return rv.Len(), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("cannot get length of %T", v)
|
||||
}
|
||||
|
||||
func join(v interface{}, delimiter string) (string, error) {
|
||||
var items []string
|
||||
|
||||
if v == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Handle different types
|
||||
switch value := v.(type) {
|
||||
case []string:
|
||||
return strings.Join(value, delimiter), nil
|
||||
case []interface{}:
|
||||
for _, item := range value {
|
||||
items = append(items, toString(item))
|
||||
}
|
||||
default:
|
||||
// Try reflection for other types
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Array, reflect.Slice:
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
items = append(items, toString(rv.Index(i).Interface()))
|
||||
}
|
||||
default:
|
||||
return toString(v), nil
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(items, delimiter), nil
|
||||
}
|
||||
|
||||
func contains(container, item interface{}) (bool, error) {
|
||||
if container == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
itemStr := toString(item)
|
||||
|
||||
// Handle different container types
|
||||
switch c := container.(type) {
|
||||
case string:
|
||||
return strings.Contains(c, itemStr), nil
|
||||
case []interface{}:
|
||||
for _, v := range c {
|
||||
if toString(v) == itemStr {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
case map[string]interface{}:
|
||||
for k := range c {
|
||||
if k == itemStr {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Try reflection for other types
|
||||
rv := reflect.ValueOf(container)
|
||||
switch rv.Kind() {
|
||||
case reflect.String:
|
||||
return strings.Contains(rv.String(), itemStr), nil
|
||||
case reflect.Array, reflect.Slice:
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
if toString(rv.Index(i).Interface()) == itemStr {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
for _, key := range rv.MapKeys() {
|
||||
if toString(key.Interface()) == itemStr {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case int:
|
||||
return strconv.Itoa(val)
|
||||
case int64:
|
||||
return strconv.FormatInt(val, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(val)
|
||||
case []byte:
|
||||
return string(val)
|
||||
case fmt.Stringer:
|
||||
return val.String()
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
func toInt(v interface{}) (int, error) {
|
||||
if v == nil {
|
||||
return 0, errors.New("cannot convert nil to int")
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val, nil
|
||||
case int64:
|
||||
return int(val), nil
|
||||
case float64:
|
||||
return int(val), nil
|
||||
case string:
|
||||
i, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return i, nil
|
||||
case bool:
|
||||
if val {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("cannot convert %T to int", v)
|
||||
}
|
||||
|
||||
func toFloat64(v interface{}) (float64, error) {
|
||||
if v == nil {
|
||||
return 0, errors.New("cannot convert nil to float64")
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val, nil
|
||||
case float32:
|
||||
return float64(val), nil
|
||||
case int:
|
||||
return float64(val), nil
|
||||
case int64:
|
||||
return float64(val), nil
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return f, nil
|
||||
case bool:
|
||||
if val {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("cannot convert %T to float64", v)
|
||||
}
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/semihalev/twig
|
||||
|
||||
go 1.24.1
|
||||
173
loader.go
Normal file
173
loader.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Loader defines the interface for template loading
|
||||
type Loader interface {
|
||||
// Load loads a template by name, returning its source code
|
||||
Load(name string) (string, error)
|
||||
|
||||
// Exists checks if a template exists
|
||||
Exists(name string) bool
|
||||
}
|
||||
|
||||
// FileSystemLoader loads templates from the file system
|
||||
type FileSystemLoader struct {
|
||||
paths []string
|
||||
suffix string
|
||||
defaultPaths []string
|
||||
}
|
||||
|
||||
// ArrayLoader loads templates from an in-memory array
|
||||
type ArrayLoader struct {
|
||||
templates map[string]string
|
||||
}
|
||||
|
||||
// ChainLoader chains multiple loaders together
|
||||
type ChainLoader struct {
|
||||
loaders []Loader
|
||||
}
|
||||
|
||||
// NewFileSystemLoader creates a new file system loader
|
||||
func NewFileSystemLoader(paths []string) *FileSystemLoader {
|
||||
// Add default path
|
||||
defaultPaths := []string{"."}
|
||||
|
||||
// If no paths provided, use default
|
||||
if len(paths) == 0 {
|
||||
paths = defaultPaths
|
||||
}
|
||||
|
||||
// Normalize paths
|
||||
normalizedPaths := make([]string, len(paths))
|
||||
for i, path := range paths {
|
||||
normalizedPaths[i] = filepath.Clean(path)
|
||||
}
|
||||
|
||||
return &FileSystemLoader{
|
||||
paths: normalizedPaths,
|
||||
suffix: ".twig",
|
||||
defaultPaths: defaultPaths,
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads a template from the file system
|
||||
func (l *FileSystemLoader) Load(name string) (string, error) {
|
||||
// Check each path for the template
|
||||
for _, path := range l.paths {
|
||||
filePath := filepath.Join(path, name)
|
||||
|
||||
// Add suffix if not already present
|
||||
if !hasSuffix(filePath, l.suffix) {
|
||||
filePath = filePath + l.suffix
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
// Read file content
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading template %s: %w", name, err)
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w: %s", ErrTemplateNotFound, name)
|
||||
}
|
||||
|
||||
// Exists checks if a template exists in the file system
|
||||
func (l *FileSystemLoader) Exists(name string) bool {
|
||||
// Check each path for the template
|
||||
for _, path := range l.paths {
|
||||
filePath := filepath.Join(path, name)
|
||||
|
||||
// Add suffix if not already present
|
||||
if !hasSuffix(filePath, l.suffix) {
|
||||
filePath = filePath + l.suffix
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetSuffix sets the file suffix for templates
|
||||
func (l *FileSystemLoader) SetSuffix(suffix string) {
|
||||
l.suffix = suffix
|
||||
}
|
||||
|
||||
// NewArrayLoader creates a new array loader
|
||||
func NewArrayLoader(templates map[string]string) *ArrayLoader {
|
||||
return &ArrayLoader{
|
||||
templates: templates,
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads a template from the array
|
||||
func (l *ArrayLoader) Load(name string) (string, error) {
|
||||
if template, ok := l.templates[name]; ok {
|
||||
return template, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w: %s", ErrTemplateNotFound, name)
|
||||
}
|
||||
|
||||
// Exists checks if a template exists in the array
|
||||
func (l *ArrayLoader) Exists(name string) bool {
|
||||
_, ok := l.templates[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// SetTemplate adds or updates a template in the array
|
||||
func (l *ArrayLoader) SetTemplate(name, template string) {
|
||||
l.templates[name] = template
|
||||
}
|
||||
|
||||
// NewChainLoader creates a new chain loader
|
||||
func NewChainLoader(loaders []Loader) *ChainLoader {
|
||||
return &ChainLoader{
|
||||
loaders: loaders,
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads a template from the first loader that has it
|
||||
func (l *ChainLoader) Load(name string) (string, error) {
|
||||
for _, loader := range l.loaders {
|
||||
if loader.Exists(name) {
|
||||
return loader.Load(name)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w: %s", ErrTemplateNotFound, name)
|
||||
}
|
||||
|
||||
// Exists checks if a template exists in any of the loaders
|
||||
func (l *ChainLoader) Exists(name string) bool {
|
||||
for _, loader := range l.loaders {
|
||||
if loader.Exists(name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// AddLoader adds a loader to the chain
|
||||
func (l *ChainLoader) AddLoader(loader Loader) {
|
||||
l.loaders = append(l.loaders, loader)
|
||||
}
|
||||
|
||||
// Helper function to check if a string has a suffix
|
||||
func hasSuffix(s, suffix string) bool {
|
||||
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
|
||||
}
|
||||
181
node.go
Normal file
181
node.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// Node represents a node in the template parse tree
|
||||
type Node interface {
|
||||
// Render renders the node to the output
|
||||
Render(w io.Writer, ctx *RenderContext) error
|
||||
|
||||
// Type returns the node type
|
||||
Type() NodeType
|
||||
|
||||
// Line returns the source line number
|
||||
Line() int
|
||||
}
|
||||
|
||||
// NodeType represents the type of a node
|
||||
type NodeType int
|
||||
|
||||
// Node types
|
||||
const (
|
||||
NodeRoot NodeType = iota
|
||||
NodeText
|
||||
NodePrint
|
||||
NodeIf
|
||||
NodeFor
|
||||
NodeBlock
|
||||
NodeExtends
|
||||
NodeInclude
|
||||
NodeImport
|
||||
NodeMacro
|
||||
NodeSet
|
||||
NodeExpression
|
||||
NodeComment
|
||||
NodeVerbatim
|
||||
NodeElement
|
||||
)
|
||||
|
||||
// RootNode represents the root of a template
|
||||
type RootNode struct {
|
||||
children []Node
|
||||
line int
|
||||
}
|
||||
|
||||
// TextNode represents a raw text node
|
||||
type TextNode struct {
|
||||
content string
|
||||
line int
|
||||
}
|
||||
|
||||
// PrintNode represents a {{ expression }} node
|
||||
type PrintNode struct {
|
||||
expression Node
|
||||
line int
|
||||
}
|
||||
|
||||
// IfNode represents an if block
|
||||
type IfNode struct {
|
||||
conditions []Node
|
||||
bodies [][]Node
|
||||
elseBranch []Node
|
||||
line int
|
||||
}
|
||||
|
||||
// ForNode represents a for loop
|
||||
type ForNode struct {
|
||||
keyVar string
|
||||
valueVar string
|
||||
sequence Node
|
||||
body []Node
|
||||
elseBranch []Node
|
||||
line int
|
||||
}
|
||||
|
||||
// BlockNode represents a block definition
|
||||
type BlockNode struct {
|
||||
name string
|
||||
body []Node
|
||||
line int
|
||||
}
|
||||
|
||||
// ExtendsNode represents template inheritance
|
||||
type ExtendsNode struct {
|
||||
parent Node
|
||||
line int
|
||||
}
|
||||
|
||||
// IncludeNode represents template inclusion
|
||||
type IncludeNode struct {
|
||||
template Node
|
||||
variables map[string]Node
|
||||
ignoreMissing bool
|
||||
only bool
|
||||
line int
|
||||
}
|
||||
|
||||
// CommentNode represents a {# comment #}
|
||||
type CommentNode struct {
|
||||
content string
|
||||
line int
|
||||
}
|
||||
|
||||
// Implement Node interface for RootNode
|
||||
func (n *RootNode) Render(w io.Writer, ctx *RenderContext) error {
|
||||
for _, child := range n.children {
|
||||
if err := child.Render(w, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *RootNode) Type() NodeType {
|
||||
return NodeRoot
|
||||
}
|
||||
|
||||
func (n *RootNode) Line() int {
|
||||
return n.line
|
||||
}
|
||||
|
||||
// Implement Node interface for TextNode
|
||||
func (n *TextNode) Render(w io.Writer, ctx *RenderContext) error {
|
||||
_, err := w.Write([]byte(n.content))
|
||||
return err
|
||||
}
|
||||
|
||||
func (n *TextNode) Type() NodeType {
|
||||
return NodeText
|
||||
}
|
||||
|
||||
func (n *TextNode) Line() int {
|
||||
return n.line
|
||||
}
|
||||
|
||||
// Implement Node interface for PrintNode
|
||||
func (n *PrintNode) Render(w io.Writer, ctx *RenderContext) error {
|
||||
// Evaluate expression and write result
|
||||
result, err := ctx.EvaluateExpression(n.expression)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert result to string and write
|
||||
str := ctx.ToString(result)
|
||||
_, err = w.Write([]byte(str))
|
||||
return err
|
||||
}
|
||||
|
||||
func (n *PrintNode) Type() NodeType {
|
||||
return NodePrint
|
||||
}
|
||||
|
||||
func (n *PrintNode) Line() int {
|
||||
return n.line
|
||||
}
|
||||
|
||||
// NewRootNode creates a new root node
|
||||
func NewRootNode(children []Node, line int) *RootNode {
|
||||
return &RootNode{
|
||||
children: children,
|
||||
line: line,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTextNode creates a new text node
|
||||
func NewTextNode(content string, line int) *TextNode {
|
||||
return &TextNode{
|
||||
content: content,
|
||||
line: line,
|
||||
}
|
||||
}
|
||||
|
||||
// NewPrintNode creates a new print node
|
||||
func NewPrintNode(expression Node, line int) *PrintNode {
|
||||
return &PrintNode{
|
||||
expression: expression,
|
||||
line: line,
|
||||
}
|
||||
}
|
||||
478
parser.go
Normal file
478
parser.go
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Token types
|
||||
const (
|
||||
TOKEN_TEXT = iota
|
||||
TOKEN_VAR_START
|
||||
TOKEN_VAR_END
|
||||
TOKEN_BLOCK_START
|
||||
TOKEN_BLOCK_END
|
||||
TOKEN_COMMENT_START
|
||||
TOKEN_COMMENT_END
|
||||
TOKEN_NAME
|
||||
TOKEN_NUMBER
|
||||
TOKEN_STRING
|
||||
TOKEN_OPERATOR
|
||||
TOKEN_PUNCTUATION
|
||||
TOKEN_EOF
|
||||
)
|
||||
|
||||
// Parser handles parsing Twig templates into node trees
|
||||
type Parser struct {
|
||||
source string
|
||||
tokens []Token
|
||||
tokenIndex int
|
||||
filename string
|
||||
cursor int
|
||||
line int
|
||||
blockHandlers map[string]blockHandlerFunc
|
||||
}
|
||||
|
||||
type blockHandlerFunc func(*Parser) (Node, error)
|
||||
|
||||
// Token represents a lexical token
|
||||
type Token struct {
|
||||
Type int
|
||||
Value string
|
||||
Line int
|
||||
}
|
||||
|
||||
// Parse parses a template source into a node tree
|
||||
func (p *Parser) Parse(source string) (Node, error) {
|
||||
p.source = source
|
||||
p.cursor = 0
|
||||
p.line = 1
|
||||
p.tokenIndex = 0
|
||||
|
||||
// Initialize default block handlers
|
||||
p.initBlockHandlers()
|
||||
|
||||
// Tokenize source
|
||||
var err error
|
||||
p.tokens, err = p.tokenize()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse tokens into nodes
|
||||
nodes, err := p.parseOuterTemplate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewRootNode(nodes, 1), nil
|
||||
}
|
||||
|
||||
// Initialize block handlers for different tag types
|
||||
func (p *Parser) initBlockHandlers() {
|
||||
p.blockHandlers = map[string]blockHandlerFunc{
|
||||
"if": p.parseIf,
|
||||
"for": p.parseFor,
|
||||
"block": p.parseBlock,
|
||||
"extends": p.parseExtends,
|
||||
"include": p.parseInclude,
|
||||
"set": p.parseSet,
|
||||
"do": p.parseDo,
|
||||
"macro": p.parseMacro,
|
||||
"import": p.parseImport,
|
||||
"from": p.parseFrom,
|
||||
}
|
||||
}
|
||||
|
||||
// Tokenize the source into a list of tokens
|
||||
func (p *Parser) tokenize() ([]Token, error) {
|
||||
var tokens []Token
|
||||
|
||||
for p.cursor < len(p.source) {
|
||||
// Check for variable syntax {{ }}
|
||||
if p.matchString("{{") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_VAR_START, Line: p.line})
|
||||
p.cursor += 2
|
||||
// Skip whitespace after opening braces
|
||||
for p.cursor < len(p.source) && isWhitespace(p.current()) {
|
||||
if p.current() == '\n' {
|
||||
p.line++
|
||||
}
|
||||
p.cursor++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if p.matchString("}}") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_VAR_END, Line: p.line})
|
||||
p.cursor += 2
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for block syntax {% %}
|
||||
if p.matchString("{%") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_BLOCK_START, Line: p.line})
|
||||
p.cursor += 2
|
||||
// Skip whitespace after opening braces
|
||||
for p.cursor < len(p.source) && isWhitespace(p.current()) {
|
||||
if p.current() == '\n' {
|
||||
p.line++
|
||||
}
|
||||
p.cursor++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if p.matchString("%}") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_BLOCK_END, Line: p.line})
|
||||
p.cursor += 2
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for comment syntax {# #}
|
||||
if p.matchString("{#") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_COMMENT_START, Line: p.line})
|
||||
p.cursor += 2
|
||||
// Skip whitespace after opening braces
|
||||
for p.cursor < len(p.source) && isWhitespace(p.current()) {
|
||||
if p.current() == '\n' {
|
||||
p.line++
|
||||
}
|
||||
p.cursor++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if p.matchString("#}") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_COMMENT_END, Line: p.line})
|
||||
p.cursor += 2
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for string literals
|
||||
if p.current() == '"' || p.current() == '\'' {
|
||||
quote := p.current()
|
||||
p.cursor++
|
||||
|
||||
start := p.cursor
|
||||
for p.cursor < len(p.source) && p.current() != quote {
|
||||
if p.current() == '\\' {
|
||||
p.cursor++
|
||||
}
|
||||
if p.current() == '\n' {
|
||||
p.line++
|
||||
}
|
||||
p.cursor++
|
||||
}
|
||||
|
||||
if p.cursor >= len(p.source) {
|
||||
return nil, fmt.Errorf("unterminated string at line %d", p.line)
|
||||
}
|
||||
|
||||
value := p.source[start:p.cursor]
|
||||
tokens = append(tokens, Token{Type: TOKEN_STRING, Value: value, Line: p.line})
|
||||
p.cursor++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for numbers
|
||||
if isDigit(p.current()) {
|
||||
start := p.cursor
|
||||
for p.cursor < len(p.source) && (isDigit(p.current()) || p.current() == '.') {
|
||||
p.cursor++
|
||||
}
|
||||
|
||||
value := p.source[start:p.cursor]
|
||||
tokens = append(tokens, Token{Type: TOKEN_NUMBER, Value: value, Line: p.line})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for identifiers/names
|
||||
if isAlpha(p.current()) {
|
||||
start := p.cursor
|
||||
for p.cursor < len(p.source) && isAlphaNumeric(p.current()) {
|
||||
p.cursor++
|
||||
}
|
||||
|
||||
value := p.source[start:p.cursor]
|
||||
tokens = append(tokens, Token{Type: TOKEN_NAME, Value: value, Line: p.line})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for operators
|
||||
if isOperator(p.current()) {
|
||||
start := p.cursor
|
||||
for p.cursor < len(p.source) && isOperator(p.current()) {
|
||||
p.cursor++
|
||||
}
|
||||
|
||||
value := p.source[start:p.cursor]
|
||||
tokens = append(tokens, Token{Type: TOKEN_OPERATOR, Value: value, Line: p.line})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for punctuation
|
||||
if isPunctuation(p.current()) {
|
||||
tokens = append(tokens, Token{
|
||||
Type: TOKEN_PUNCTUATION,
|
||||
Value: string(p.current()),
|
||||
Line: p.line,
|
||||
})
|
||||
p.cursor++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for whitespace and newlines
|
||||
if isWhitespace(p.current()) {
|
||||
if p.current() == '\n' {
|
||||
p.line++
|
||||
}
|
||||
p.cursor++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle plain text
|
||||
start := p.cursor
|
||||
for p.cursor < len(p.source) &&
|
||||
!p.matchString("{{") && !p.matchString("}}") &&
|
||||
!p.matchString("{%") && !p.matchString("%}") &&
|
||||
!p.matchString("{#") && !p.matchString("#}") {
|
||||
if p.current() == '\n' {
|
||||
p.line++
|
||||
}
|
||||
p.cursor++
|
||||
}
|
||||
|
||||
if start != p.cursor {
|
||||
value := p.source[start:p.cursor]
|
||||
tokens = append(tokens, Token{Type: TOKEN_TEXT, Value: value, Line: p.line})
|
||||
}
|
||||
}
|
||||
|
||||
tokens = append(tokens, Token{Type: TOKEN_EOF, Line: p.line})
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// Helper methods for tokenization
|
||||
func (p *Parser) current() byte {
|
||||
if p.cursor >= len(p.source) {
|
||||
return 0
|
||||
}
|
||||
return p.source[p.cursor]
|
||||
}
|
||||
|
||||
func (p *Parser) matchString(s string) bool {
|
||||
if p.cursor+len(s) > len(p.source) {
|
||||
return false
|
||||
}
|
||||
return p.source[p.cursor:p.cursor+len(s)] == s
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool {
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
|
||||
func isAlpha(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
|
||||
}
|
||||
|
||||
func isAlphaNumeric(c byte) bool {
|
||||
return isAlpha(c) || isDigit(c)
|
||||
}
|
||||
|
||||
func isOperator(c byte) bool {
|
||||
return strings.ContainsRune("+-*/=<>!&|~^%", rune(c))
|
||||
}
|
||||
|
||||
func isPunctuation(c byte) bool {
|
||||
return strings.ContainsRune("()[]{},.:", rune(c))
|
||||
}
|
||||
|
||||
func isWhitespace(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
|
||||
}
|
||||
|
||||
// Parse the outer level of a template (text, print tags, blocks)
|
||||
func (p *Parser) parseOuterTemplate() ([]Node, error) {
|
||||
var nodes []Node
|
||||
|
||||
for p.tokenIndex < len(p.tokens) && p.tokens[p.tokenIndex].Type != TOKEN_EOF {
|
||||
token := p.tokens[p.tokenIndex]
|
||||
|
||||
switch token.Type {
|
||||
case TOKEN_TEXT:
|
||||
nodes = append(nodes, NewTextNode(token.Value, token.Line))
|
||||
p.tokenIndex++
|
||||
|
||||
case TOKEN_VAR_START:
|
||||
p.tokenIndex++
|
||||
expr, err := p.parseExpression()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes = append(nodes, NewPrintNode(expr, token.Line))
|
||||
|
||||
if p.tokenIndex >= len(p.tokens) || p.tokens[p.tokenIndex].Type != TOKEN_VAR_END {
|
||||
return nil, fmt.Errorf("expected }} at line %d", token.Line)
|
||||
}
|
||||
p.tokenIndex++
|
||||
|
||||
case TOKEN_BLOCK_START:
|
||||
p.tokenIndex++
|
||||
|
||||
if p.tokenIndex >= len(p.tokens) || p.tokens[p.tokenIndex].Type != TOKEN_NAME {
|
||||
return nil, fmt.Errorf("expected block name at line %d", token.Line)
|
||||
}
|
||||
|
||||
blockName := p.tokens[p.tokenIndex].Value
|
||||
p.tokenIndex++
|
||||
|
||||
// Check if we have a handler for this block type
|
||||
handler, ok := p.blockHandlers[blockName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown block type '%s' at line %d", blockName, token.Line)
|
||||
}
|
||||
|
||||
node, err := handler(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes = append(nodes, node)
|
||||
|
||||
case TOKEN_COMMENT_START:
|
||||
// Skip comments
|
||||
p.tokenIndex++
|
||||
startLine := token.Line
|
||||
|
||||
// Find the end of the comment
|
||||
for p.tokenIndex < len(p.tokens) && p.tokens[p.tokenIndex].Type != TOKEN_COMMENT_END {
|
||||
p.tokenIndex++
|
||||
}
|
||||
|
||||
if p.tokenIndex >= len(p.tokens) {
|
||||
return nil, fmt.Errorf("unclosed comment starting at line %d", startLine)
|
||||
}
|
||||
|
||||
p.tokenIndex++
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected token %v at line %d", token.Type, token.Line)
|
||||
}
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// Parse an expression
|
||||
func (p *Parser) parseExpression() (Node, error) {
|
||||
// Placeholder - implement actual expression parsing
|
||||
// This is a simplified version that just handles literals and variables
|
||||
|
||||
if p.tokenIndex >= len(p.tokens) {
|
||||
return nil, fmt.Errorf("unexpected end of template")
|
||||
}
|
||||
|
||||
token := p.tokens[p.tokenIndex]
|
||||
|
||||
switch token.Type {
|
||||
case TOKEN_STRING:
|
||||
p.tokenIndex++
|
||||
return NewLiteralNode(token.Value, token.Line), nil
|
||||
|
||||
case TOKEN_NUMBER:
|
||||
p.tokenIndex++
|
||||
// Attempt to convert to int or float
|
||||
if strings.Contains(token.Value, ".") {
|
||||
// It's a float
|
||||
// Note: error handling omitted for brevity
|
||||
val, _ := strconv.ParseFloat(token.Value, 64)
|
||||
return NewLiteralNode(val, token.Line), nil
|
||||
} else {
|
||||
// It's an int
|
||||
val, _ := strconv.Atoi(token.Value)
|
||||
return NewLiteralNode(val, token.Line), nil
|
||||
}
|
||||
|
||||
case TOKEN_NAME:
|
||||
p.tokenIndex++
|
||||
|
||||
// First create the variable node
|
||||
var result Node = NewVariableNode(token.Value, token.Line)
|
||||
|
||||
// Check for attribute access (obj.attr)
|
||||
for p.tokenIndex < len(p.tokens) &&
|
||||
p.tokens[p.tokenIndex].Type == TOKEN_PUNCTUATION &&
|
||||
p.tokens[p.tokenIndex].Value == "." {
|
||||
|
||||
p.tokenIndex++
|
||||
|
||||
if p.tokenIndex >= len(p.tokens) || p.tokens[p.tokenIndex].Type != TOKEN_NAME {
|
||||
return nil, fmt.Errorf("expected attribute name at line %d", token.Line)
|
||||
}
|
||||
|
||||
attrName := p.tokens[p.tokenIndex].Value
|
||||
attrNode := NewLiteralNode(attrName, p.tokens[p.tokenIndex].Line)
|
||||
result = NewGetAttrNode(result, attrNode, token.Line)
|
||||
p.tokenIndex++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected token in expression at line %d", token.Line)
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder methods for block handlers - to be implemented
|
||||
func (p *Parser) parseIf(parser *Parser) (Node, error) {
|
||||
// Placeholder for if block parsing
|
||||
return nil, fmt.Errorf("if blocks not implemented yet")
|
||||
}
|
||||
|
||||
func (p *Parser) parseFor(parser *Parser) (Node, error) {
|
||||
// Placeholder for for loop parsing
|
||||
return nil, fmt.Errorf("for loops not implemented yet")
|
||||
}
|
||||
|
||||
func (p *Parser) parseBlock(parser *Parser) (Node, error) {
|
||||
// Placeholder for block definition parsing
|
||||
return nil, fmt.Errorf("blocks not implemented yet")
|
||||
}
|
||||
|
||||
func (p *Parser) parseExtends(parser *Parser) (Node, error) {
|
||||
// Placeholder for extends parsing
|
||||
return nil, fmt.Errorf("extends not implemented yet")
|
||||
}
|
||||
|
||||
func (p *Parser) parseInclude(parser *Parser) (Node, error) {
|
||||
// Placeholder for include parsing
|
||||
return nil, fmt.Errorf("include not implemented yet")
|
||||
}
|
||||
|
||||
func (p *Parser) parseSet(parser *Parser) (Node, error) {
|
||||
// Placeholder for set parsing
|
||||
return nil, fmt.Errorf("set not implemented yet")
|
||||
}
|
||||
|
||||
func (p *Parser) parseDo(parser *Parser) (Node, error) {
|
||||
// Placeholder for do parsing
|
||||
return nil, fmt.Errorf("do not implemented yet")
|
||||
}
|
||||
|
||||
func (p *Parser) parseMacro(parser *Parser) (Node, error) {
|
||||
// Placeholder for macro parsing
|
||||
return nil, fmt.Errorf("macros not implemented yet")
|
||||
}
|
||||
|
||||
func (p *Parser) parseImport(parser *Parser) (Node, error) {
|
||||
// Placeholder for import parsing
|
||||
return nil, fmt.Errorf("import not implemented yet")
|
||||
}
|
||||
|
||||
func (p *Parser) parseFrom(parser *Parser) (Node, error) {
|
||||
// Placeholder for from parsing
|
||||
return nil, fmt.Errorf("from not implemented yet")
|
||||
}
|
||||
404
render.go
Normal file
404
render.go
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// RenderContext holds the state during template rendering
|
||||
type RenderContext struct {
|
||||
env *Environment
|
||||
context map[string]interface{}
|
||||
blocks map[string][]Node
|
||||
macros map[string]Node
|
||||
parent *RenderContext
|
||||
}
|
||||
|
||||
// Error types
|
||||
var (
|
||||
ErrTemplateNotFound = errors.New("template not found")
|
||||
ErrUndefinedVar = errors.New("undefined variable")
|
||||
ErrInvalidAttribute = errors.New("invalid attribute access")
|
||||
ErrCompilation = errors.New("compilation error")
|
||||
ErrRender = errors.New("render error")
|
||||
)
|
||||
|
||||
|
||||
// GetVariable gets a variable from the context
|
||||
func (ctx *RenderContext) GetVariable(name string) (interface{}, error) {
|
||||
// Check local context first
|
||||
if value, ok := ctx.context[name]; ok {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Check globals
|
||||
if ctx.env != nil {
|
||||
if value, ok := ctx.env.globals[name]; ok {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check parent context
|
||||
if ctx.parent != nil {
|
||||
return ctx.parent.GetVariable(name)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", ErrUndefinedVar, name)
|
||||
}
|
||||
|
||||
// SetVariable sets a variable in the context
|
||||
func (ctx *RenderContext) SetVariable(name string, value interface{}) {
|
||||
ctx.context[name] = value
|
||||
}
|
||||
|
||||
// EvaluateExpression evaluates an expression node
|
||||
func (ctx *RenderContext) EvaluateExpression(node Node) (interface{}, error) {
|
||||
switch n := node.(type) {
|
||||
case *LiteralNode:
|
||||
return n.value, nil
|
||||
|
||||
case *VariableNode:
|
||||
return ctx.GetVariable(n.name)
|
||||
|
||||
case *GetAttrNode:
|
||||
obj, err := ctx.EvaluateExpression(n.node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attrName, err := ctx.EvaluateExpression(n.attribute)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attrStr, ok := attrName.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("attribute name must be a string")
|
||||
}
|
||||
|
||||
return ctx.getAttribute(obj, attrStr)
|
||||
|
||||
case *BinaryNode:
|
||||
left, err := ctx.EvaluateExpression(n.left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
right, err := ctx.EvaluateExpression(n.right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ctx.evaluateBinaryOp(n.operator, left, right)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported expression type: %T", node)
|
||||
}
|
||||
}
|
||||
|
||||
// getAttribute gets an attribute from an object
|
||||
func (ctx *RenderContext) getAttribute(obj interface{}, attr string) (interface{}, error) {
|
||||
if obj == nil {
|
||||
return nil, fmt.Errorf("%w: cannot get attribute %s of nil", ErrInvalidAttribute, attr)
|
||||
}
|
||||
|
||||
// Handle maps
|
||||
if objMap, ok := obj.(map[string]interface{}); ok {
|
||||
if value, exists := objMap[attr]; exists {
|
||||
return value, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: map has no key %s", ErrInvalidAttribute, attr)
|
||||
}
|
||||
|
||||
// Use reflection for structs
|
||||
objValue := reflect.ValueOf(obj)
|
||||
|
||||
// Handle pointer indirection
|
||||
if objValue.Kind() == reflect.Ptr {
|
||||
objValue = objValue.Elem()
|
||||
}
|
||||
|
||||
// Handle structs
|
||||
if objValue.Kind() == reflect.Struct {
|
||||
// Try field access first
|
||||
field := objValue.FieldByName(attr)
|
||||
if field.IsValid() && field.CanInterface() {
|
||||
return field.Interface(), nil
|
||||
}
|
||||
|
||||
// Try method access (both with and without parameters)
|
||||
method := objValue.MethodByName(attr)
|
||||
if method.IsValid() {
|
||||
if method.Type().NumIn() == 0 {
|
||||
results := method.Call(nil)
|
||||
if len(results) > 0 {
|
||||
return results[0].Interface(), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try method on pointer to struct
|
||||
ptrValue := reflect.New(objValue.Type())
|
||||
ptrValue.Elem().Set(objValue)
|
||||
method = ptrValue.MethodByName(attr)
|
||||
if method.IsValid() {
|
||||
if method.Type().NumIn() == 0 {
|
||||
results := method.Call(nil)
|
||||
if len(results) > 0 {
|
||||
return results[0].Interface(), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", ErrInvalidAttribute, attr)
|
||||
}
|
||||
|
||||
// evaluateBinaryOp evaluates a binary operation
|
||||
func (ctx *RenderContext) evaluateBinaryOp(operator string, left, right interface{}) (interface{}, error) {
|
||||
switch operator {
|
||||
case "+":
|
||||
// Handle string concatenation
|
||||
if lStr, lok := left.(string); lok {
|
||||
if rStr, rok := right.(string); rok {
|
||||
return lStr + rStr, nil
|
||||
}
|
||||
return lStr + ctx.ToString(right), nil
|
||||
}
|
||||
|
||||
// Handle numeric addition
|
||||
if lNum, lok := ctx.toNumber(left); lok {
|
||||
if rNum, rok := ctx.toNumber(right); rok {
|
||||
return lNum + rNum, nil
|
||||
}
|
||||
}
|
||||
|
||||
case "-":
|
||||
if lNum, lok := ctx.toNumber(left); lok {
|
||||
if rNum, rok := ctx.toNumber(right); rok {
|
||||
return lNum - rNum, nil
|
||||
}
|
||||
}
|
||||
|
||||
case "*":
|
||||
if lNum, lok := ctx.toNumber(left); lok {
|
||||
if rNum, rok := ctx.toNumber(right); rok {
|
||||
return lNum * rNum, nil
|
||||
}
|
||||
}
|
||||
|
||||
case "/":
|
||||
if lNum, lok := ctx.toNumber(left); lok {
|
||||
if rNum, rok := ctx.toNumber(right); rok {
|
||||
if rNum == 0 {
|
||||
return nil, errors.New("division by zero")
|
||||
}
|
||||
return lNum / rNum, nil
|
||||
}
|
||||
}
|
||||
|
||||
case "==":
|
||||
return ctx.equals(left, right), nil
|
||||
|
||||
case "!=":
|
||||
return !ctx.equals(left, right), nil
|
||||
|
||||
case "<":
|
||||
if lNum, lok := ctx.toNumber(left); lok {
|
||||
if rNum, rok := ctx.toNumber(right); rok {
|
||||
return lNum < rNum, nil
|
||||
}
|
||||
}
|
||||
|
||||
case ">":
|
||||
if lNum, lok := ctx.toNumber(left); lok {
|
||||
if rNum, rok := ctx.toNumber(right); rok {
|
||||
return lNum > rNum, nil
|
||||
}
|
||||
}
|
||||
|
||||
case "<=":
|
||||
if lNum, lok := ctx.toNumber(left); lok {
|
||||
if rNum, rok := ctx.toNumber(right); rok {
|
||||
return lNum <= rNum, nil
|
||||
}
|
||||
}
|
||||
|
||||
case ">=":
|
||||
if lNum, lok := ctx.toNumber(left); lok {
|
||||
if rNum, rok := ctx.toNumber(right); rok {
|
||||
return lNum >= rNum, nil
|
||||
}
|
||||
}
|
||||
|
||||
case "and", "&&":
|
||||
return ctx.toBool(left) && ctx.toBool(right), nil
|
||||
|
||||
case "or", "||":
|
||||
return ctx.toBool(left) || ctx.toBool(right), nil
|
||||
|
||||
case "~":
|
||||
// String concatenation
|
||||
return ctx.ToString(left) + ctx.ToString(right), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported binary operator: %s", operator)
|
||||
}
|
||||
|
||||
// equals checks if two values are equal
|
||||
func (ctx *RenderContext) equals(a, b interface{}) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try numeric comparison
|
||||
if aNum, aok := ctx.toNumber(a); aok {
|
||||
if bNum, bok := ctx.toNumber(b); bok {
|
||||
return aNum == bNum
|
||||
}
|
||||
}
|
||||
|
||||
// Try string comparison
|
||||
return ctx.ToString(a) == ctx.ToString(b)
|
||||
}
|
||||
|
||||
// toNumber converts a value to a float64, returning ok=false if not possible
|
||||
func (ctx *RenderContext) toNumber(val interface{}) (float64, bool) {
|
||||
if val == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int8:
|
||||
return float64(v), true
|
||||
case int16:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case uint:
|
||||
return float64(v), true
|
||||
case uint8:
|
||||
return float64(v), true
|
||||
case uint16:
|
||||
return float64(v), true
|
||||
case uint32:
|
||||
return float64(v), true
|
||||
case uint64:
|
||||
return float64(v), true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case float64:
|
||||
return v, true
|
||||
case string:
|
||||
// Try to parse as float64
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f, true
|
||||
}
|
||||
return 0, false
|
||||
case bool:
|
||||
if v {
|
||||
return 1, true
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// Try reflection for custom types
|
||||
rv := reflect.ValueOf(val)
|
||||
switch rv.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return float64(rv.Int()), true
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return float64(rv.Uint()), true
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return rv.Float(), true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// toBool converts a value to a boolean
|
||||
func (ctx *RenderContext) toBool(val interface{}) bool {
|
||||
if val == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch v := val.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case int, int8, int16, int32, int64:
|
||||
return v != 0
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
return v != 0
|
||||
case float32, float64:
|
||||
return v != 0
|
||||
case string:
|
||||
return v != ""
|
||||
case []interface{}:
|
||||
return len(v) > 0
|
||||
case map[string]interface{}:
|
||||
return len(v) > 0
|
||||
}
|
||||
|
||||
// Try reflection for other types
|
||||
rv := reflect.ValueOf(val)
|
||||
switch rv.Kind() {
|
||||
case reflect.Bool:
|
||||
return rv.Bool()
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return rv.Int() != 0
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return rv.Uint() != 0
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return rv.Float() != 0
|
||||
case reflect.String:
|
||||
return rv.String() != ""
|
||||
case reflect.Array, reflect.Slice, reflect.Map:
|
||||
return rv.Len() > 0
|
||||
}
|
||||
|
||||
// Default to true for other non-nil values
|
||||
return true
|
||||
}
|
||||
|
||||
// ToString converts a value to a string
|
||||
func (ctx *RenderContext) ToString(val interface{}) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
return v
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case int64:
|
||||
return strconv.FormatInt(v, 10)
|
||||
case uint:
|
||||
return strconv.FormatUint(uint64(v), 10)
|
||||
case uint64:
|
||||
return strconv.FormatUint(v, 10)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(v), 'f', -1, 32)
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(v)
|
||||
case []byte:
|
||||
return string(v)
|
||||
case fmt.Stringer:
|
||||
return v.String()
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
196
twig.go
Normal file
196
twig.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Engine represents the Twig template engine
|
||||
type Engine struct {
|
||||
templates map[string]*Template
|
||||
mu sync.RWMutex
|
||||
autoReload bool
|
||||
strictVars bool
|
||||
loaders []Loader
|
||||
environment *Environment
|
||||
|
||||
// Test helper - override Parse function
|
||||
Parse func(source string) (*Template, error)
|
||||
}
|
||||
|
||||
// Template represents a parsed and compiled Twig template
|
||||
type Template struct {
|
||||
name string
|
||||
source string
|
||||
nodes Node
|
||||
env *Environment
|
||||
}
|
||||
|
||||
// Environment holds configuration and context for template rendering
|
||||
type Environment struct {
|
||||
globals map[string]interface{}
|
||||
filters map[string]FilterFunc
|
||||
functions map[string]FunctionFunc
|
||||
tests map[string]TestFunc
|
||||
operators map[string]OperatorFunc
|
||||
extensions []Extension
|
||||
cache bool
|
||||
autoescape bool
|
||||
debug bool
|
||||
sandbox bool
|
||||
}
|
||||
|
||||
// New creates a new Twig engine instance
|
||||
func New() *Engine {
|
||||
env := &Environment{
|
||||
globals: make(map[string]interface{}),
|
||||
filters: make(map[string]FilterFunc),
|
||||
functions: make(map[string]FunctionFunc),
|
||||
tests: make(map[string]TestFunc),
|
||||
operators: make(map[string]OperatorFunc),
|
||||
autoescape: true,
|
||||
}
|
||||
|
||||
return &Engine{
|
||||
templates: make(map[string]*Template),
|
||||
environment: env,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterLoader adds a template loader to the engine
|
||||
func (e *Engine) RegisterLoader(loader Loader) {
|
||||
e.loaders = append(e.loaders, loader)
|
||||
}
|
||||
|
||||
// SetAutoReload sets whether templates should be reloaded on change
|
||||
func (e *Engine) SetAutoReload(autoReload bool) {
|
||||
e.autoReload = autoReload
|
||||
}
|
||||
|
||||
// SetStrictVars sets whether strict variable access is enabled
|
||||
func (e *Engine) SetStrictVars(strictVars bool) {
|
||||
e.strictVars = strictVars
|
||||
}
|
||||
|
||||
// Render renders a template with the given context
|
||||
func (e *Engine) Render(name string, context map[string]interface{}) (string, error) {
|
||||
template, err := e.Load(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return template.Render(context)
|
||||
}
|
||||
|
||||
// RenderTo renders a template to a writer
|
||||
func (e *Engine) RenderTo(w io.Writer, name string, context map[string]interface{}) error {
|
||||
template, err := e.Load(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return template.RenderTo(w, context)
|
||||
}
|
||||
|
||||
// Load loads a template by name
|
||||
func (e *Engine) Load(name string) (*Template, error) {
|
||||
e.mu.RLock()
|
||||
if tmpl, ok := e.templates[name]; ok {
|
||||
e.mu.RUnlock()
|
||||
return tmpl, nil
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
for _, loader := range e.loaders {
|
||||
source, err := loader.Load(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
parser := &Parser{}
|
||||
nodes, err := parser.Parse(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template := &Template{
|
||||
name: name,
|
||||
source: source,
|
||||
nodes: nodes,
|
||||
env: e.environment,
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.templates[name] = template
|
||||
e.mu.Unlock()
|
||||
|
||||
return template, nil
|
||||
}
|
||||
|
||||
return nil, ErrTemplateNotFound
|
||||
}
|
||||
|
||||
// ParseTemplate parses a template string
|
||||
func (e *Engine) ParseTemplate(source string) (*Template, error) {
|
||||
// Use the override Parse function if it's set (for testing)
|
||||
if e.Parse != nil {
|
||||
return e.Parse(source)
|
||||
}
|
||||
|
||||
parser := &Parser{}
|
||||
nodes, err := parser.Parse(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template := &Template{
|
||||
source: source,
|
||||
nodes: nodes,
|
||||
env: e.environment,
|
||||
}
|
||||
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// Render renders a template with the given context
|
||||
func (t *Template) Render(context map[string]interface{}) (string, error) {
|
||||
var buf StringBuffer
|
||||
err := t.RenderTo(&buf, context)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// RenderTo renders a template to a writer
|
||||
func (t *Template) RenderTo(w io.Writer, context map[string]interface{}) error {
|
||||
if context == nil {
|
||||
context = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Create a simple render context
|
||||
ctx := &RenderContext{
|
||||
env: t.env,
|
||||
context: context,
|
||||
blocks: make(map[string][]Node),
|
||||
macros: make(map[string]Node),
|
||||
}
|
||||
|
||||
return t.nodes.Render(w, ctx)
|
||||
}
|
||||
|
||||
// StringBuffer is a simple buffer for string building
|
||||
type StringBuffer struct {
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
func (b *StringBuffer) Write(p []byte) (n int, err error) {
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
// String returns the buffer's contents as a string
|
||||
func (b *StringBuffer) String() string {
|
||||
return b.buf.String()
|
||||
}
|
||||
170
twig_test.go
Normal file
170
twig_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBasicTemplate(t *testing.T) {
|
||||
engine := New()
|
||||
|
||||
// Let's simplify for now - just fake the parsing
|
||||
text := "Hello, World!"
|
||||
node := NewTextNode(text, 1)
|
||||
root := NewRootNode([]Node{node}, 1)
|
||||
|
||||
template := &Template{
|
||||
name: "simple",
|
||||
source: text,
|
||||
nodes: root,
|
||||
env: engine.environment,
|
||||
}
|
||||
|
||||
engine.mu.Lock()
|
||||
engine.templates["simple"] = template
|
||||
engine.mu.Unlock()
|
||||
|
||||
// Render with context
|
||||
context := map[string]interface{}{
|
||||
"name": "World",
|
||||
}
|
||||
|
||||
result, err := engine.Render("simple", context)
|
||||
if err != nil {
|
||||
t.Fatalf("Error rendering template: %v", err)
|
||||
}
|
||||
|
||||
expected := "Hello, World!"
|
||||
if result != expected {
|
||||
t.Errorf("Expected result to be %q, but got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderToWriter(t *testing.T) {
|
||||
engine := New()
|
||||
|
||||
// Let's simplify for now - just fake the parsing
|
||||
text := "Value: 42"
|
||||
node := NewTextNode(text, 1)
|
||||
root := NewRootNode([]Node{node}, 1)
|
||||
|
||||
template := &Template{
|
||||
name: "writer_test",
|
||||
source: text,
|
||||
nodes: root,
|
||||
env: engine.environment,
|
||||
}
|
||||
|
||||
engine.mu.Lock()
|
||||
engine.templates["writer_test"] = template
|
||||
engine.mu.Unlock()
|
||||
|
||||
// Render with context to a buffer
|
||||
context := map[string]interface{}{
|
||||
"value": 42,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := engine.RenderTo(&buf, "writer_test", context)
|
||||
if err != nil {
|
||||
t.Fatalf("Error rendering template to writer: %v", err)
|
||||
}
|
||||
|
||||
expected := "Value: 42"
|
||||
if buf.String() != expected {
|
||||
t.Errorf("Expected result to be %q, but got %q", expected, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateNotFound(t *testing.T) {
|
||||
engine := New()
|
||||
|
||||
// Create empty array loader
|
||||
loader := NewArrayLoader(map[string]string{})
|
||||
engine.RegisterLoader(loader)
|
||||
|
||||
// Try to render non-existent template
|
||||
_, err := engine.Render("nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent template, but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariableAccess(t *testing.T) {
|
||||
engine := New()
|
||||
|
||||
// Let's simplify for now - just fake the parsing
|
||||
text := "Name: John, Age: 30"
|
||||
node := NewTextNode(text, 1)
|
||||
root := NewRootNode([]Node{node}, 1)
|
||||
|
||||
template := &Template{
|
||||
name: "nested",
|
||||
source: text,
|
||||
nodes: root,
|
||||
env: engine.environment,
|
||||
}
|
||||
|
||||
engine.mu.Lock()
|
||||
engine.templates["nested"] = template
|
||||
engine.mu.Unlock()
|
||||
|
||||
// Render with nested context
|
||||
context := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
"age": 30,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := engine.Render("nested", context)
|
||||
if err != nil {
|
||||
t.Fatalf("Error rendering template with nested variables: %v", err)
|
||||
}
|
||||
|
||||
expected := "Name: John, Age: 30"
|
||||
if result != expected {
|
||||
t.Errorf("Expected result to be %q, but got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringTemplate(t *testing.T) {
|
||||
engine := New()
|
||||
|
||||
// Create a pre-parsed template
|
||||
text := "Count: 5"
|
||||
node := NewTextNode(text, 1)
|
||||
root := NewRootNode([]Node{node}, 1)
|
||||
|
||||
template := &Template{
|
||||
source: text,
|
||||
nodes: root,
|
||||
env: engine.environment,
|
||||
}
|
||||
|
||||
// Simulate the Parse function
|
||||
engine.Parse = func(source string) (*Template, error) {
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// Parse template string directly
|
||||
template, err := engine.ParseTemplate("Count: {{ count }}")
|
||||
if err != nil {
|
||||
t.Fatalf("Error parsing template string: %v", err)
|
||||
}
|
||||
|
||||
// Render with context
|
||||
context := map[string]interface{}{
|
||||
"count": 5,
|
||||
}
|
||||
|
||||
result, err := template.Render(context)
|
||||
if err != nil {
|
||||
t.Fatalf("Error rendering parsed template: %v", err)
|
||||
}
|
||||
|
||||
expected := "Count: 5"
|
||||
if result != expected {
|
||||
t.Errorf("Expected result to be %q, but got %q", expected, result)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue