mirror of
https://github.com/semihalev/twig.git
synced 2026-03-14 13:55:46 +01:00
Implement whitespace control features
- Add support for whitespace control modifiers ({{-, -}}, {%-, -%})
- Implement {% spaceless %} tag for HTML whitespace removal
- Add token types for whitespace control tokens
- Improve text node handling to preserve spaces between words
- Add comprehensive tests for whitespace control features
- Update documentation in README.md and PROGRESS.md
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c4faeb33d6
commit
96983b22c1
8 changed files with 502 additions and 926 deletions
15
PROGRESS.md
15
PROGRESS.md
|
|
@ -58,17 +58,26 @@
|
|||
- Improved performance for templates with multiple filters applied to the same value
|
||||
- Added tests and benchmarks to verify correctness and performance gains
|
||||
|
||||
## Recent Improvements
|
||||
|
||||
1. **Whitespace Control Features**
|
||||
- Added support for whitespace control modifiers (`{{-`, `-}}`, `{%-`, `-%}`)
|
||||
- Implemented the `{% spaceless %}` tag to remove whitespace between HTML tags
|
||||
- Added direct tokenizer support for whitespace control tokens
|
||||
- Improved text handling to preserve spaces between words
|
||||
- Added tests for all whitespace control features
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **More Tests**
|
||||
2. **More Tests**
|
||||
- Add more comprehensive tests for edge cases
|
||||
- Add more benchmarks for different template scenarios
|
||||
|
||||
2. **Error Handling**
|
||||
3. **Error Handling**
|
||||
- Improve error messages for filter-related issues
|
||||
- Add better debugging support
|
||||
|
||||
3. **Template Compilation**
|
||||
4. **Template Compilation**
|
||||
- Implement a compiled template format for even faster rendering
|
||||
- Add the ability to pre-compile templates for production use
|
||||
|
||||
|
|
|
|||
41
README.md
41
README.md
|
|
@ -279,6 +279,46 @@ By default, Twig runs in production mode:
|
|||
- Auto-reload is disabled to avoid unnecessary file system checks
|
||||
- Debug mode is disabled to reduce overhead
|
||||
|
||||
## Whitespace Handling
|
||||
|
||||
Twig templates can have significant whitespace that affects the rendered output. This implementation supports several mechanisms for controlling whitespace:
|
||||
|
||||
### Whitespace Control Features
|
||||
|
||||
1. **Whitespace Control Modifiers**
|
||||
|
||||
The whitespace control modifiers (`-` character) allow you to trim whitespace around tags:
|
||||
|
||||
```twig
|
||||
<div>
|
||||
{{- greeting -}} {# Removes whitespace before and after #}
|
||||
</div>
|
||||
```
|
||||
|
||||
Using these modifiers:
|
||||
- `{{- ... }}`: Removes whitespace before the variable output
|
||||
- `{{ ... -}}`: Removes whitespace after the variable output
|
||||
- `{{- ... -}}`: Removes whitespace both before and after
|
||||
- `{%- ... %}`: Removes whitespace before the block tag
|
||||
- `{% ... -%}`: Removes whitespace after the block tag
|
||||
- `{%- ... -%}`: Removes whitespace both before and after
|
||||
|
||||
2. **Spaceless Tag**
|
||||
|
||||
The `spaceless` tag removes whitespace between HTML tags (but preserves whitespace within text content):
|
||||
|
||||
```twig
|
||||
{% spaceless %}
|
||||
<div>
|
||||
<strong>Whitespace is removed between HTML tags</strong>
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
```
|
||||
|
||||
This produces: `<div><strong>Whitespace is removed between HTML tags</strong></div>`
|
||||
|
||||
These features help you create cleaner output, especially when generating HTML with proper indentation in templates but needing compact output for production.
|
||||
|
||||
## Performance
|
||||
|
||||
The library is designed with performance in mind:
|
||||
|
|
@ -286,6 +326,7 @@ The library is designed with performance in mind:
|
|||
- Efficient parsing and rendering
|
||||
- Template caching
|
||||
- Production/development mode toggle
|
||||
- Optimized filter chain processing
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
9
node.go
9
node.go
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Node represents a node in the template parse tree
|
||||
|
|
@ -39,6 +40,7 @@ const (
|
|||
NodeVerbatim
|
||||
NodeElement
|
||||
NodeFunction
|
||||
NodeSpaceless
|
||||
)
|
||||
|
||||
// RootNode represents the root of a template
|
||||
|
|
@ -53,6 +55,13 @@ type TextNode struct {
|
|||
line int
|
||||
}
|
||||
|
||||
// String implementation for debugging
|
||||
func (n *TextNode) String() string {
|
||||
// Display spaces as visible characters for easier debugging
|
||||
spacesVisual := strings.ReplaceAll(n.content, " ", "·")
|
||||
return fmt.Sprintf("TextNode(%q [%s], line: %d)", n.content, spacesVisual, n.line)
|
||||
}
|
||||
|
||||
// PrintNode represents a {{ expression }} node
|
||||
type PrintNode struct {
|
||||
expression Node
|
||||
|
|
|
|||
233
parser.go
233
parser.go
|
|
@ -9,18 +9,24 @@ import (
|
|||
// 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_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
|
||||
|
||||
// Whitespace control token types
|
||||
TOKEN_VAR_START_TRIM // {{-
|
||||
TOKEN_VAR_END_TRIM // -}}
|
||||
TOKEN_BLOCK_START_TRIM // {%-
|
||||
TOKEN_BLOCK_END_TRIM // -%}
|
||||
)
|
||||
|
||||
// Parser handles parsing Twig templates into node trees
|
||||
|
|
@ -68,6 +74,11 @@ func (p *Parser) Parse(source string) (Node, error) {
|
|||
}
|
||||
*/
|
||||
|
||||
// Apply whitespace control processing to the tokens to handle
|
||||
// the whitespace trimming between template elements
|
||||
p.tokens = processWhitespaceControl(p.tokens)
|
||||
|
||||
|
||||
// Parse tokens into nodes
|
||||
nodes, err := p.parseOuterTemplate()
|
||||
if err != nil {
|
||||
|
|
@ -80,24 +91,26 @@ func (p *Parser) Parse(source string) (Node, error) {
|
|||
// 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,
|
||||
"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,
|
||||
"spaceless": p.parseSpaceless,
|
||||
|
||||
// Special closing tags - they will be handled in their corresponding open tag parsers
|
||||
"endif": p.parseEndTag,
|
||||
"endfor": p.parseEndTag,
|
||||
"endmacro": p.parseEndTag,
|
||||
"endblock": p.parseEndTag,
|
||||
"else": p.parseEndTag,
|
||||
"elseif": p.parseEndTag,
|
||||
"endif": p.parseEndTag,
|
||||
"endfor": p.parseEndTag,
|
||||
"endmacro": p.parseEndTag,
|
||||
"endblock": p.parseEndTag,
|
||||
"endspaceless": p.parseEndTag,
|
||||
"else": p.parseEndTag,
|
||||
"elseif": p.parseEndTag,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,8 +119,19 @@ func (p *Parser) tokenize() ([]Token, error) {
|
|||
var tokens []Token
|
||||
|
||||
for p.cursor < len(p.source) {
|
||||
// Check for variable syntax {{ }}
|
||||
if p.matchString("{{") {
|
||||
// Check for variable syntax with whitespace control {{ }} or {{- -}}
|
||||
if p.matchString("{{-") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_VAR_START_TRIM, Line: p.line})
|
||||
p.cursor += 3
|
||||
// Skip whitespace after opening braces
|
||||
for p.cursor < len(p.source) && isWhitespace(p.current()) {
|
||||
if p.current() == '\n' {
|
||||
p.line++
|
||||
}
|
||||
p.cursor++
|
||||
}
|
||||
continue
|
||||
} else if p.matchString("{{") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_VAR_START, Line: p.line})
|
||||
p.cursor += 2
|
||||
// Skip whitespace after opening braces
|
||||
|
|
@ -120,14 +144,29 @@ func (p *Parser) tokenize() ([]Token, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
if p.matchString("}}") {
|
||||
if p.matchString("-}}") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_VAR_END_TRIM, Line: p.line})
|
||||
p.cursor += 3
|
||||
continue
|
||||
} else 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("{%") {
|
||||
// Check for block syntax with whitespace control {% %} or {%- -%}
|
||||
if p.matchString("{%-") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_BLOCK_START_TRIM, Line: p.line})
|
||||
p.cursor += 3
|
||||
// Skip whitespace after opening braces
|
||||
for p.cursor < len(p.source) && isWhitespace(p.current()) {
|
||||
if p.current() == '\n' {
|
||||
p.line++
|
||||
}
|
||||
p.cursor++
|
||||
}
|
||||
continue
|
||||
} else if p.matchString("{%") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_BLOCK_START, Line: p.line})
|
||||
p.cursor += 2
|
||||
// Skip whitespace after opening braces
|
||||
|
|
@ -140,7 +179,11 @@ func (p *Parser) tokenize() ([]Token, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
if p.matchString("%}") {
|
||||
if p.matchString("-%}") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_BLOCK_END_TRIM, Line: p.line})
|
||||
p.cursor += 3
|
||||
continue
|
||||
} else if p.matchString("%}") {
|
||||
tokens = append(tokens, Token{Type: TOKEN_BLOCK_END, Line: p.line})
|
||||
p.cursor += 2
|
||||
continue
|
||||
|
|
@ -268,8 +311,10 @@ func (p *Parser) tokenize() ([]Token, error) {
|
|||
// Handle plain text
|
||||
start := p.cursor
|
||||
for p.cursor < len(p.source) &&
|
||||
!p.matchString("{{") && !p.matchString("}}") &&
|
||||
!p.matchString("{%") && !p.matchString("%}") &&
|
||||
!p.matchString("{{-") && !p.matchString("{{") &&
|
||||
!p.matchString("-}}") && !p.matchString("}}") &&
|
||||
!p.matchString("{%-") && !p.matchString("{%") &&
|
||||
!p.matchString("-%}") && !p.matchString("%}") &&
|
||||
!p.matchString("{#") && !p.matchString("#}") {
|
||||
if p.current() == '\n' {
|
||||
p.line++
|
||||
|
|
@ -295,6 +340,16 @@ func (p *Parser) current() byte {
|
|||
return p.source[p.cursor]
|
||||
}
|
||||
|
||||
// Helper function to check if a token is any kind of block end token (regular or trim variant)
|
||||
func isBlockEndToken(tokenType int) bool {
|
||||
return tokenType == TOKEN_BLOCK_END || tokenType == TOKEN_BLOCK_END_TRIM
|
||||
}
|
||||
|
||||
// Helper function to check if a token is any kind of variable end token (regular or trim variant)
|
||||
func isVarEndToken(tokenType int) bool {
|
||||
return tokenType == TOKEN_VAR_END || tokenType == TOKEN_VAR_END_TRIM
|
||||
}
|
||||
|
||||
func (p *Parser) matchString(s string) bool {
|
||||
if p.cursor+len(s) > len(p.source) {
|
||||
return false
|
||||
|
|
@ -400,7 +455,8 @@ func (p *Parser) parseOuterTemplate() ([]Node, error) {
|
|||
nodes = append(nodes, NewTextNode(token.Value, token.Line))
|
||||
p.tokenIndex++
|
||||
|
||||
case TOKEN_VAR_START:
|
||||
case TOKEN_VAR_START, TOKEN_VAR_START_TRIM:
|
||||
// Handle both normal and whitespace trimming var start tokens
|
||||
p.tokenIndex++
|
||||
expr, err := p.parseExpression()
|
||||
if err != nil {
|
||||
|
|
@ -409,12 +465,14 @@ func (p *Parser) parseOuterTemplate() ([]Node, error) {
|
|||
|
||||
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)
|
||||
// Check for either normal or whitespace trimming var end tokens
|
||||
if p.tokenIndex >= len(p.tokens) || !isVarEndToken(p.tokens[p.tokenIndex].Type) {
|
||||
return nil, fmt.Errorf("expected }} or -}} at line %d", token.Line)
|
||||
}
|
||||
p.tokenIndex++
|
||||
|
||||
case TOKEN_BLOCK_START:
|
||||
case TOKEN_BLOCK_START, TOKEN_BLOCK_START_TRIM:
|
||||
// Handle both normal and whitespace trimming block start tokens
|
||||
p.tokenIndex++
|
||||
|
||||
if p.tokenIndex >= len(p.tokens) || p.tokens[p.tokenIndex].Type != TOKEN_NAME {
|
||||
|
|
@ -426,7 +484,8 @@ func (p *Parser) parseOuterTemplate() ([]Node, error) {
|
|||
|
||||
// Check if this is a control ending tag (endif, endfor, endblock, etc.)
|
||||
if blockName == "endif" || blockName == "endfor" || blockName == "endblock" ||
|
||||
blockName == "endmacro" || blockName == "else" || blockName == "elseif" {
|
||||
blockName == "endmacro" || blockName == "else" || blockName == "elseif" ||
|
||||
blockName == "endspaceless" {
|
||||
// We should return to the parent parser that's handling the parent block
|
||||
// First move back two steps to the start of the block tag
|
||||
p.tokenIndex -= 2
|
||||
|
|
@ -462,11 +521,44 @@ func (p *Parser) parseOuterTemplate() ([]Node, error) {
|
|||
|
||||
p.tokenIndex++
|
||||
|
||||
// Add special handling for trim token types
|
||||
case TOKEN_VAR_END_TRIM, TOKEN_BLOCK_END_TRIM:
|
||||
// These should have been handled with their corresponding start tokens
|
||||
return nil, fmt.Errorf("unexpected token %v at line %d", token.Type, token.Line)
|
||||
|
||||
// Add special handling for TOKEN_NAME outside of a tag
|
||||
case TOKEN_NAME, TOKEN_PUNCTUATION, TOKEN_OPERATOR, TOKEN_STRING, TOKEN_NUMBER:
|
||||
// For raw names, punctuation, operators, and literals not inside tags, convert to text
|
||||
nodes = append(nodes, NewTextNode(token.Value, token.Line))
|
||||
p.tokenIndex++
|
||||
// In many languages, the text "true" is a literal boolean, but in our parser it's just a name token
|
||||
// outside of an expression context
|
||||
|
||||
// Special handling for text content words - add spaces between consecutive text tokens
|
||||
// This fixes issues with the spaceless tag's handling of text content
|
||||
if token.Type == TOKEN_NAME && p.tokenIndex+1 < len(p.tokens) &&
|
||||
p.tokens[p.tokenIndex+1].Type == TOKEN_NAME &&
|
||||
p.tokens[p.tokenIndex+1].Line == token.Line {
|
||||
// Look ahead for consecutive name tokens and join them with spaces
|
||||
var textContent strings.Builder
|
||||
textContent.WriteString(token.Value)
|
||||
|
||||
currentLine := token.Line
|
||||
p.tokenIndex++ // Skip the first token as we've already added it
|
||||
|
||||
// Collect consecutive name tokens on the same line
|
||||
for p.tokenIndex < len(p.tokens) &&
|
||||
p.tokens[p.tokenIndex].Type == TOKEN_NAME &&
|
||||
p.tokens[p.tokenIndex].Line == currentLine {
|
||||
textContent.WriteString(" ") // Add space between words
|
||||
textContent.WriteString(p.tokens[p.tokenIndex].Value)
|
||||
p.tokenIndex++
|
||||
}
|
||||
|
||||
nodes = append(nodes, NewTextNode(textContent.String(), token.Line))
|
||||
} else {
|
||||
// Regular handling for single text tokens
|
||||
nodes = append(nodes, NewTextNode(token.Value, token.Line))
|
||||
p.tokenIndex++
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected token %v at line %d", token.Type, token.Line)
|
||||
|
|
@ -598,6 +690,15 @@ func (p *Parser) parseSimpleExpression() (Node, error) {
|
|||
// Store the variable name for function calls
|
||||
varName := token.Value
|
||||
varLine := token.Line
|
||||
|
||||
// Special handling for boolean literals and null
|
||||
if varName == "true" {
|
||||
return NewLiteralNode(true, varLine), nil
|
||||
} else if varName == "false" {
|
||||
return NewLiteralNode(false, varLine), nil
|
||||
} else if varName == "null" || varName == "nil" {
|
||||
return NewLiteralNode(nil, varLine), nil
|
||||
}
|
||||
|
||||
// Check if this is a function call (name followed by opening parenthesis)
|
||||
if p.tokenIndex < len(p.tokens) &&
|
||||
|
|
@ -978,8 +1079,10 @@ func (p *Parser) parseIf(parser *Parser) (Node, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Expect the block end token
|
||||
if parser.tokenIndex >= len(parser.tokens) || parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END {
|
||||
// Expect the block end token (either regular or whitespace-trimming variant)
|
||||
if parser.tokenIndex >= len(parser.tokens) ||
|
||||
(parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END &&
|
||||
parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END_TRIM) {
|
||||
return nil, fmt.Errorf("expected block end after if condition at line %d", ifLine)
|
||||
}
|
||||
parser.tokenIndex++
|
||||
|
|
@ -1036,8 +1139,8 @@ func (p *Parser) parseIf(parser *Parser) (Node, error) {
|
|||
return nil, fmt.Errorf("expected else or endif, got %s at line %d", parser.tokens[parser.tokenIndex].Value, parser.tokens[parser.tokenIndex].Line)
|
||||
}
|
||||
|
||||
// Expect the final block end token
|
||||
if parser.tokenIndex >= len(parser.tokens) || parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END {
|
||||
// Expect the final block end token (either regular or trim variant)
|
||||
if parser.tokenIndex >= len(parser.tokens) || !isBlockEndToken(parser.tokens[parser.tokenIndex].Type) {
|
||||
return nil, fmt.Errorf("expected block end after endif at line %d", parser.tokens[parser.tokenIndex-1].Line)
|
||||
}
|
||||
parser.tokenIndex++
|
||||
|
|
@ -1104,8 +1207,8 @@ func (p *Parser) parseFor(parser *Parser) (Node, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Expect the block end token
|
||||
if parser.tokenIndex >= len(parser.tokens) || parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END {
|
||||
// Expect the block end token (either regular or trim variant)
|
||||
if parser.tokenIndex >= len(parser.tokens) || !isBlockEndToken(parser.tokens[parser.tokenIndex].Type) {
|
||||
return nil, fmt.Errorf("expected block end after for statement at line %d", forLine)
|
||||
}
|
||||
parser.tokenIndex++
|
||||
|
|
@ -1666,3 +1769,47 @@ func (p *Parser) parseEndTag(parser *Parser) (Node, error) {
|
|||
|
||||
return nil, fmt.Errorf("unexpected '%s' tag at line %d", tagName, tagLine)
|
||||
}
|
||||
|
||||
// parseSpaceless parses a spaceless block
|
||||
func (p *Parser) parseSpaceless(parser *Parser) (Node, error) {
|
||||
// Get the line number of the spaceless token
|
||||
spacelessLine := parser.tokens[parser.tokenIndex-2].Line
|
||||
|
||||
// Expect the block end token
|
||||
if parser.tokenIndex >= len(parser.tokens) ||
|
||||
(parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END &&
|
||||
parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END_TRIM) {
|
||||
return nil, fmt.Errorf("expected block end token after spaceless at line %d", spacelessLine)
|
||||
}
|
||||
parser.tokenIndex++
|
||||
|
||||
// Parse the spaceless body
|
||||
spacelessBody, err := parser.parseOuterTemplate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Expect endspaceless tag
|
||||
if parser.tokenIndex >= len(parser.tokens) || parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_START {
|
||||
return nil, fmt.Errorf("expected endspaceless tag at line %d", spacelessLine)
|
||||
}
|
||||
parser.tokenIndex++
|
||||
|
||||
// Expect the endspaceless token
|
||||
if parser.tokenIndex >= len(parser.tokens) || parser.tokens[parser.tokenIndex].Type != TOKEN_NAME ||
|
||||
parser.tokens[parser.tokenIndex].Value != "endspaceless" {
|
||||
return nil, fmt.Errorf("expected endspaceless token at line %d", parser.tokens[parser.tokenIndex].Line)
|
||||
}
|
||||
parser.tokenIndex++
|
||||
|
||||
// Expect the block end token
|
||||
if parser.tokenIndex >= len(parser.tokens) ||
|
||||
(parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END &&
|
||||
parser.tokens[parser.tokenIndex].Type != TOKEN_BLOCK_END_TRIM) {
|
||||
return nil, fmt.Errorf("expected block end token after endspaceless at line %d", parser.tokens[parser.tokenIndex].Line)
|
||||
}
|
||||
parser.tokenIndex++
|
||||
|
||||
// Create and return the spaceless node
|
||||
return NewSpacelessNode(spacelessBody, spacelessLine), nil
|
||||
}
|
||||
|
|
|
|||
880
render.go.bak
880
render.go.bak
|
|
@ -1,880 +0,0 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
engine *Engine // Reference to engine for loading templates
|
||||
extending bool // Whether this template extends another
|
||||
currentBlock *BlockNode // Current block being rendered (for parent() function)
|
||||
}
|
||||
|
||||
// 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 with no error for undefined variables
|
||||
// Twig treats undefined variables as empty strings during rendering
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SetVariable sets a variable in the context
|
||||
func (ctx *RenderContext) SetVariable(name string, value interface{}) {
|
||||
ctx.context[name] = value
|
||||
}
|
||||
|
||||
// GetMacro gets a macro from the context
|
||||
func (ctx *RenderContext) GetMacro(name string) (Node, bool) {
|
||||
// Check local macros first
|
||||
if macro, ok := ctx.macros[name]; ok {
|
||||
return macro, true
|
||||
}
|
||||
|
||||
// Check parent context
|
||||
if ctx.parent != nil {
|
||||
return ctx.parent.GetMacro(name)
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// CallMacro calls a macro with the given arguments
|
||||
func (ctx *RenderContext) CallMacro(w io.Writer, name string, args []interface{}) error {
|
||||
// Find the macro
|
||||
macro, ok := ctx.GetMacro(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("macro '%s' not found", name)
|
||||
}
|
||||
|
||||
// Check if it's a MacroNode
|
||||
macroNode, ok := macro.(*MacroNode)
|
||||
if !ok {
|
||||
return fmt.Errorf("'%s' is not a macro", name)
|
||||
}
|
||||
|
||||
// Call the macro
|
||||
return macroNode.Call(w, ctx, args)
|
||||
}
|
||||
|
||||
// CallFunction calls a function with the given arguments
|
||||
func (ctx *RenderContext) CallFunction(name string, args []interface{}) (interface{}, error) {
|
||||
// Check if it's a function in the environment
|
||||
if ctx.env != nil {
|
||||
if fn, ok := ctx.env.functions[name]; ok {
|
||||
return fn(args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a built-in function
|
||||
switch name {
|
||||
case "range":
|
||||
return ctx.callRangeFunction(args)
|
||||
case "length", "count":
|
||||
return ctx.callLengthFunction(args)
|
||||
case "max":
|
||||
return ctx.callMaxFunction(args)
|
||||
case "min":
|
||||
return ctx.callMinFunction(args)
|
||||
}
|
||||
|
||||
// Check if it's a macro
|
||||
if macro, ok := ctx.GetMacro(name); ok {
|
||||
// Return a callable function
|
||||
return func(w io.Writer) error {
|
||||
macroNode, ok := macro.(*MacroNode)
|
||||
if !ok {
|
||||
return fmt.Errorf("'%s' is not a macro", name)
|
||||
}
|
||||
return macroNode.Call(w, ctx, args)
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("function '%s' not found", name)
|
||||
}
|
||||
|
||||
// callRangeFunction implements the range function
|
||||
func (ctx *RenderContext) callRangeFunction(args []interface{}) (interface{}, error) {
|
||||
if len(args) < 2 {
|
||||
return nil, fmt.Errorf("range function requires at least 2 arguments")
|
||||
}
|
||||
|
||||
// Get the start and end values
|
||||
start, ok1 := ctx.toNumber(args[0])
|
||||
end, ok2 := ctx.toNumber(args[1])
|
||||
|
||||
if !ok1 || !ok2 {
|
||||
return nil, fmt.Errorf("range arguments must be numbers")
|
||||
}
|
||||
|
||||
// Get the step value (default is 1)
|
||||
step := 1.0
|
||||
if len(args) > 2 {
|
||||
if s, ok := ctx.toNumber(args[2]); ok {
|
||||
step = s
|
||||
}
|
||||
}
|
||||
|
||||
// Create the range
|
||||
var result []int
|
||||
for i := start; i <= end; i += step {
|
||||
result = append(result, int(i))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// callLengthFunction implements the length/count function
|
||||
func (ctx *RenderContext) callLengthFunction(args []interface{}) (interface{}, error) {
|
||||
if len(args) != 1 {
|
||||
return nil, fmt.Errorf("length/count function requires exactly 1 argument")
|
||||
}
|
||||
|
||||
val := args[0]
|
||||
v := reflect.ValueOf(val)
|
||||
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return len(v.String()), nil
|
||||
case reflect.Slice, reflect.Array:
|
||||
return v.Len(), nil
|
||||
case reflect.Map:
|
||||
return v.Len(), nil
|
||||
default:
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// callMaxFunction implements the max function
|
||||
func (ctx *RenderContext) callMaxFunction(args []interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("max function requires at least 1 argument")
|
||||
}
|
||||
|
||||
// If the argument is a slice or array, find the max value in it
|
||||
if len(args) == 1 {
|
||||
v := reflect.ValueOf(args[0])
|
||||
if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
|
||||
if v.Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
max := v.Index(0).Interface()
|
||||
maxNum, ok := ctx.toNumber(max)
|
||||
if !ok {
|
||||
return max, nil
|
||||
}
|
||||
|
||||
for i := 1; i < v.Len(); i++ {
|
||||
val := v.Index(i).Interface()
|
||||
if valNum, ok := ctx.toNumber(val); ok {
|
||||
if valNum > maxNum {
|
||||
max = val
|
||||
maxNum = valNum
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return max, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Find the max value in the arguments
|
||||
max := args[0]
|
||||
maxNum, ok := ctx.toNumber(max)
|
||||
if !ok {
|
||||
return max, nil
|
||||
}
|
||||
|
||||
for i := 1; i < len(args); i++ {
|
||||
val := args[i]
|
||||
if valNum, ok := ctx.toNumber(val); ok {
|
||||
if valNum > maxNum {
|
||||
max = val
|
||||
maxNum = valNum
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return max, nil
|
||||
}
|
||||
|
||||
// callMinFunction implements the min function
|
||||
func (ctx *RenderContext) callMinFunction(args []interface{}) (interface{}, error) {
|
||||
if len(args) < 1 {
|
||||
return nil, fmt.Errorf("min function requires at least 1 argument")
|
||||
}
|
||||
|
||||
// If the argument is a slice or array, find the min value in it
|
||||
if len(args) == 1 {
|
||||
v := reflect.ValueOf(args[0])
|
||||
if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
|
||||
if v.Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
min := v.Index(0).Interface()
|
||||
minNum, ok := ctx.toNumber(min)
|
||||
if !ok {
|
||||
return min, nil
|
||||
}
|
||||
|
||||
for i := 1; i < v.Len(); i++ {
|
||||
val := v.Index(i).Interface()
|
||||
if valNum, ok := ctx.toNumber(val); ok {
|
||||
if valNum < minNum {
|
||||
min = val
|
||||
minNum = valNum
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return min, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Find the min value in the arguments
|
||||
min := args[0]
|
||||
minNum, ok := ctx.toNumber(min)
|
||||
if !ok {
|
||||
return min, nil
|
||||
}
|
||||
|
||||
for i := 1; i < len(args); i++ {
|
||||
val := args[i]
|
||||
if valNum, ok := ctx.toNumber(val); ok {
|
||||
if valNum < minNum {
|
||||
min = val
|
||||
minNum = valNum
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return min, nil
|
||||
}
|
||||
|
||||
// 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:
|
||||
// Check if it's a macro first
|
||||
if macro, ok := ctx.GetMacro(n.name); ok {
|
||||
return macro, nil
|
||||
}
|
||||
|
||||
// Otherwise, look up variable
|
||||
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")
|
||||
}
|
||||
|
||||
// Check if obj is a map containing macros (from import)
|
||||
if moduleMap, ok := obj.(map[string]interface{}); ok {
|
||||
if macro, ok := moduleMap[attrStr]; ok {
|
||||
return macro, nil
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
case *ConditionalNode:
|
||||
// Evaluate the condition
|
||||
condResult, err := ctx.EvaluateExpression(n.condition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If condition is true, evaluate the true expression, otherwise evaluate the false expression
|
||||
if ctx.toBool(condResult) {
|
||||
return ctx.EvaluateExpression(n.trueExpr)
|
||||
} else {
|
||||
return ctx.EvaluateExpression(n.falseExpr)
|
||||
}
|
||||
|
||||
case *ArrayNode:
|
||||
// Evaluate each item in the array
|
||||
items := make([]interface{}, len(n.items))
|
||||
for i, item := range n.items {
|
||||
val, err := ctx.EvaluateExpression(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[i] = val
|
||||
}
|
||||
return items, nil
|
||||
|
||||
case *FunctionNode:
|
||||
// Check if it's a macro call
|
||||
if macro, ok := ctx.GetMacro(n.name); ok {
|
||||
// Evaluate arguments
|
||||
args := make([]interface{}, len(n.args))
|
||||
for i, arg := range n.args {
|
||||
val, err := ctx.EvaluateExpression(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args[i] = val
|
||||
}
|
||||
|
||||
// Return a callable that can be rendered later
|
||||
return func(w io.Writer) error {
|
||||
macroNode, ok := macro.(*MacroNode)
|
||||
if !ok {
|
||||
return fmt.Errorf("'%s' is not a macro", n.name)
|
||||
}
|
||||
return macroNode.Call(w, ctx, args)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Otherwise, it's a regular function call
|
||||
// Evaluate arguments
|
||||
args := make([]interface{}, len(n.args))
|
||||
for i, arg := range n.args {
|
||||
val, err := ctx.EvaluateExpression(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args[i] = val
|
||||
}
|
||||
|
||||
return ctx.CallFunction(n.name, args)
|
||||
|
||||
case *FilterNode:
|
||||
// Evaluate the base value
|
||||
value, err := ctx.EvaluateExpression(n.node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Evaluate filter arguments
|
||||
args := make([]interface{}, len(n.args))
|
||||
for i, arg := range n.args {
|
||||
val, err := ctx.EvaluateExpression(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args[i] = val
|
||||
}
|
||||
|
||||
// Look for the filter in the environment
|
||||
if ctx.env != nil {
|
||||
if filter, ok := ctx.env.filters[n.filter]; ok {
|
||||
// Call the filter with the value and any arguments
|
||||
return filter(value, args...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("filter '%s' not found", n.filter)
|
||||
|
||||
case *TestNode:
|
||||
// Evaluate the tested value
|
||||
value, err := ctx.EvaluateExpression(n.node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Evaluate test arguments
|
||||
args := make([]interface{}, len(n.args))
|
||||
for i, arg := range n.args {
|
||||
val, err := ctx.EvaluateExpression(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args[i] = val
|
||||
}
|
||||
|
||||
// Look for the test in the environment
|
||||
if ctx.env != nil {
|
||||
if test, ok := ctx.env.tests[n.test]; ok {
|
||||
// Call the test with the value and any arguments
|
||||
return test(value, args...)
|
||||
}
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("test '%s' not found", n.test)
|
||||
|
||||
case *UnaryNode:
|
||||
// Evaluate the operand
|
||||
operand, err := ctx.EvaluateExpression(n.node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply the operator
|
||||
switch n.operator {
|
||||
case "not", "!":
|
||||
return !ctx.toBool(operand), nil
|
||||
case "+":
|
||||
if num, ok := ctx.toNumber(operand); ok {
|
||||
return num, nil
|
||||
}
|
||||
return 0, nil
|
||||
case "-":
|
||||
if num, ok := ctx.toNumber(operand); ok {
|
||||
return -num, nil
|
||||
}
|
||||
return 0, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported unary operator: %s", n.operator)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
case "in":
|
||||
// Check if left is in right (for arrays, slices, maps, strings)
|
||||
return ctx.contains(right, left)
|
||||
|
||||
case "not in":
|
||||
// Check if left is not in right
|
||||
contains, err := ctx.contains(right, left)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !contains, nil
|
||||
|
||||
case "matches":
|
||||
// Regular expression match
|
||||
pattern := ctx.ToString(right)
|
||||
str := ctx.ToString(left)
|
||||
|
||||
// Compile the regex
|
||||
regex, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid regular expression: %s", err)
|
||||
}
|
||||
|
||||
return regex.MatchString(str), nil
|
||||
|
||||
case "starts with":
|
||||
// String prefix check
|
||||
str := ctx.ToString(left)
|
||||
prefix := ctx.ToString(right)
|
||||
return strings.HasPrefix(str, prefix), nil
|
||||
|
||||
case "ends with":
|
||||
// String suffix check
|
||||
str := ctx.ToString(left)
|
||||
suffix := ctx.ToString(right)
|
||||
return strings.HasSuffix(str, suffix), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported binary operator: %s", operator)
|
||||
}
|
||||
|
||||
// contains checks if a value is contained in a container (string, slice, array, map)
|
||||
func (ctx *RenderContext) contains(container, item interface{}) (bool, error) {
|
||||
if container == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
itemStr := ctx.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 ctx.equals(v, item) {
|
||||
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 ctx.equals(rv.Index(i).Interface(), item) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
for _, key := range rv.MapKeys() {
|
||||
if ctx.equals(key.Interface(), item) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
43
tokenizer.go
43
tokenizer.go
|
|
@ -2,6 +2,7 @@ package twig
|
|||
|
||||
import (
|
||||
"strings"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Special processor for HTML attributes with embedded Twig variables
|
||||
|
|
@ -71,4 +72,46 @@ func processHtmlAttributesWithTwigVars(source string) []Token {
|
|||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
// Processes whitespace control modifiers in the template
|
||||
// Applies whitespace trimming to adjacent text tokens based on the token types
|
||||
// This is called after tokenization to handle the whitespace around trimming tokens
|
||||
func processWhitespaceControl(tokens []Token) []Token {
|
||||
if len(tokens) == 0 {
|
||||
return tokens
|
||||
}
|
||||
|
||||
var result []Token = make([]Token, len(tokens))
|
||||
copy(result, tokens)
|
||||
|
||||
// Process each token to apply whitespace trimming
|
||||
for i := 0; i < len(result); i++ {
|
||||
token := result[i]
|
||||
|
||||
// Handle opening tags that trim whitespace before them
|
||||
if token.Type == TOKEN_VAR_START_TRIM || token.Type == TOKEN_BLOCK_START_TRIM {
|
||||
// If there's a text token before this, trim its trailing whitespace
|
||||
if i > 0 && result[i-1].Type == TOKEN_TEXT {
|
||||
result[i-1].Value = trimTrailingWhitespace(result[i-1].Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle closing tags that trim whitespace after them
|
||||
if token.Type == TOKEN_VAR_END_TRIM || token.Type == TOKEN_BLOCK_END_TRIM {
|
||||
// If there's a text token after this, trim its leading whitespace
|
||||
if i+1 < len(result) && result[i+1].Type == TOKEN_TEXT {
|
||||
result[i+1].Value = trimLeadingWhitespace(result[i+1].Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Implement the spaceless tag functionality
|
||||
func processSpacelessTag(content string) string {
|
||||
// This regex matches whitespace between HTML tags
|
||||
re := regexp.MustCompile(">\\s+<")
|
||||
return re.ReplaceAllString(content, "><")
|
||||
}
|
||||
71
whitespace.go
Normal file
71
whitespace.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// trimLeadingWhitespace removes leading whitespace from a string
|
||||
func trimLeadingWhitespace(s string) string {
|
||||
return strings.TrimLeft(s, " \t\n\r")
|
||||
}
|
||||
|
||||
// trimTrailingWhitespace removes trailing whitespace from a string
|
||||
func trimTrailingWhitespace(s string) string {
|
||||
return strings.TrimRight(s, " \t\n\r")
|
||||
}
|
||||
|
||||
// SpacelessNode represents a {% spaceless %} ... {% endspaceless %} block
|
||||
type SpacelessNode struct {
|
||||
body []Node
|
||||
line int
|
||||
}
|
||||
|
||||
// NewSpacelessNode creates a new spaceless node
|
||||
func NewSpacelessNode(body []Node, line int) *SpacelessNode {
|
||||
return &SpacelessNode{
|
||||
body: body,
|
||||
line: line,
|
||||
}
|
||||
}
|
||||
|
||||
// Render renders the node to a writer
|
||||
func (n *SpacelessNode) Render(w io.Writer, ctx *RenderContext) error {
|
||||
// First, render the content to a string using a buffer
|
||||
var buf StringBuffer
|
||||
|
||||
for _, node := range n.body {
|
||||
if err := node.Render(&buf, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Get the rendered content as a string
|
||||
content := buf.String()
|
||||
|
||||
// Apply spaceless processing (remove whitespace between HTML tags)
|
||||
result := removeWhitespaceBetweenTags(content)
|
||||
|
||||
// Write the processed result
|
||||
_, err := w.Write([]byte(result))
|
||||
return err
|
||||
}
|
||||
|
||||
// Line returns the line number of the node
|
||||
func (n *SpacelessNode) Line() int {
|
||||
return n.line
|
||||
}
|
||||
|
||||
// Type returns the node type
|
||||
func (n *SpacelessNode) Type() NodeType {
|
||||
return NodeSpaceless
|
||||
}
|
||||
|
||||
// removeWhitespaceBetweenTags removes whitespace between HTML tags
|
||||
// but preserves whitespace between words
|
||||
func removeWhitespaceBetweenTags(content string) string {
|
||||
// This regex matches whitespace between HTML tags only
|
||||
re := regexp.MustCompile(">\\s+<")
|
||||
return re.ReplaceAllString(content, "><")
|
||||
}
|
||||
136
whitespace_test.go
Normal file
136
whitespace_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package twig
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
||||
func TestWhitespaceControl(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
template string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple whitespace control - opening tag",
|
||||
template: "Hello {{- name }}",
|
||||
expected: "HelloWorld",
|
||||
},
|
||||
{
|
||||
name: "Simple whitespace control - closing tag",
|
||||
template: "{{ name -}} World",
|
||||
expected: "WorldWorld",
|
||||
},
|
||||
{
|
||||
name: "Simple whitespace control - both sides",
|
||||
template: "Hello {{- name -}} World",
|
||||
expected: "HelloWorldWorld",
|
||||
},
|
||||
{
|
||||
name: "Block tag whitespace control",
|
||||
template: "Hello {%- if true %}Yes{% endif -%} World", // Original test case
|
||||
expected: "HelloYesWorld",
|
||||
},
|
||||
{
|
||||
name: "Newlines trimmed",
|
||||
template: "Hello\n{{- name }}\n",
|
||||
expected: "HelloWorld",
|
||||
},
|
||||
{
|
||||
name: "Complex mixed examples",
|
||||
template: "Hello\n {{- name -}} \nWorld",
|
||||
expected: "HelloWorldWorld",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
engine := New()
|
||||
|
||||
// Register the template
|
||||
err := engine.RegisterString("test.twig", tc.template)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register template: %v", err)
|
||||
}
|
||||
|
||||
// Render with context
|
||||
result, err := engine.Render("test.twig", map[string]interface{}{
|
||||
"name": "World",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to render template: %v", err)
|
||||
}
|
||||
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected: %q, got: %q for template: %q", tc.expected, result, tc.template)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpacelessTag(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
template string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Basic HTML compression",
|
||||
template: `{% spaceless %}
|
||||
<div>
|
||||
<p>Hello</p>
|
||||
<p>World</p>
|
||||
</div>
|
||||
{% endspaceless %}`,
|
||||
expected: "<div><p>Hello</p><p>World</p></div>",
|
||||
},
|
||||
{
|
||||
name: "With variables",
|
||||
template: `{% spaceless %}
|
||||
<div>
|
||||
<p>{{ greeting }}</p>
|
||||
<p>{{ name }}</p>
|
||||
</div>
|
||||
{% endspaceless %}`,
|
||||
expected: "<div><p>Hello</p><p>World</p></div>",
|
||||
},
|
||||
{
|
||||
name: "Mixed with other tags",
|
||||
template: `{% spaceless %}
|
||||
<div>
|
||||
{% if true %}
|
||||
<p>Condition is true</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endspaceless %}`,
|
||||
expected: "<div><p>Condition is true</p></div>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
engine := New()
|
||||
|
||||
// Register the template
|
||||
err := engine.RegisterString("test.twig", tc.template)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register template: %v", err)
|
||||
}
|
||||
|
||||
// Render with context
|
||||
result, err := engine.Render("test.twig", map[string]interface{}{
|
||||
"greeting": "Hello",
|
||||
"name": "World",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to render template: %v", err)
|
||||
}
|
||||
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected: %q, got: %q for template: %q", tc.expected, result, tc.template)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue