feat: initialize fork

This commit is contained in:
Reinaldy Rafli 2023-09-10 17:55:27 +07:00
commit 8072514c6f
Signed by: aldy505
GPG Key ID: A3F8A7E23DA2AD94
63 changed files with 16199 additions and 0 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "mustache"]
path = mustache
url = git://github.com/mustache/spec.git

44
CHANGELOG.md Normal file
View File

@ -0,0 +1,44 @@
# Changelog
### Handlebars 3.0.0 _(July 14, 2021)_
- Hard Fork from Raymond. Rebrand "handlebars"
- Start with major version number 3 tracking supported version of Handlebars.js
### Raymond 2.0.2 _(March 22, 2018)_
- [IMPROVEMENT] Add `RemoveHelper` and `RemoveAllHelpers` functions
- [IMPROVEMENT] Add the #equal helper (#7)
- [IMPROVEMENT] Add struct tag template variable support (#8)
### Raymond 2.0.1 _(June 01, 2016)_
- [BUGFIX] Removes data races [#3](https://github.com/aymerick/raymond/issues/3) - Thanks [@markbates](https://github.com/markbates)
### Raymond 2.0.0 _(May 01, 2016)_
- [BUGFIX] Fixes passing of context in helper options [#2](https://github.com/aymerick/raymond/issues/2) - Thanks [@GhostRussia](https://github.com/GhostRussia)
- [BREAKING] Renames and unexports constants:
- `handlebars.DUMP_TPL`
- `lexer.ESCAPED_ESCAPED_OPEN_MUSTACHE`
- `lexer.ESCAPED_OPEN_MUSTACHE`
- `lexer.OPEN_MUSTACHE`
- `lexer.CLOSE_MUSTACHE`
- `lexer.CLOSE_STRIP_MUSTACHE`
- `lexer.CLOSE_UNESCAPED_STRIP_MUSTACHE`
- `lexer.DUMP_TOKEN_POS`
- `lexer.DUMP_ALL_TOKENS_VAL`
### Raymond 1.1.0 _(June 15, 2015)_
- Permits templates references with lowercase versions of struct fields.
- Adds `ParseFile()` function.
- Adds `RegisterPartialFile()`, `RegisterPartialFiles()` and `Clone()` methods on `Template`.
- Helpers can now be struct methods.
- Ensures safe concurrent access to helpers and partials.
### Raymond 1.0.0 _(June 09, 2015)_
- This is the first release. Raymond supports almost all handlebars features. See https://github.com/aymerick/raymond#limitations for a list of differences with the javascript implementation.

24
LICENSE Normal file
View File

@ -0,0 +1,24 @@
The MIT License (MIT)
Copyright (c) 2023 Reinaldy Rafli <aldy505@proton.me>
Copyright (c) 2021 Andy Walker
Copyright (c) 2015 Aymerick JEHANNE
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.

1428
README.md Normal file

File diff suppressed because it is too large Load Diff

785
ast/node.go Normal file
View File

@ -0,0 +1,785 @@
// Package ast provides structures to represent a handlebars Abstract Syntax Tree, and a Visitor interface to visit that tree.
package ast
import (
"fmt"
"strconv"
)
// References:
// - https://github.com/wycats/handlebars.js/blob/master/lib/handlebars/compiler/ast.js
// - https://github.com/wycats/handlebars.js/blob/master/docs/compiler-api.md
// - https://github.com/golang/go/blob/master/src/text/template/parse/node.go
// Node is an element in the AST.
type Node interface {
// node type
Type() NodeType
// location of node in original input string
Location() Loc
// string representation, used for debugging
String() string
// accepts visitor
Accept(Visitor) interface{}
}
// Visitor is the interface to visit an AST.
type Visitor interface {
VisitProgram(*Program) interface{}
// statements
VisitMustache(*MustacheStatement) interface{}
VisitBlock(*BlockStatement) interface{}
VisitPartial(*PartialStatement) interface{}
VisitContent(*ContentStatement) interface{}
VisitComment(*CommentStatement) interface{}
// expressions
VisitExpression(*Expression) interface{}
VisitSubExpression(*SubExpression) interface{}
VisitPath(*PathExpression) interface{}
// literals
VisitString(*StringLiteral) interface{}
VisitBoolean(*BooleanLiteral) interface{}
VisitNumber(*NumberLiteral) interface{}
// miscellaneous
VisitHash(*Hash) interface{}
VisitHashPair(*HashPair) interface{}
}
// NodeType represents an AST Node type.
type NodeType int
// Type returns itself, and permits struct includers to satisfy that part of Node interface.
func (t NodeType) Type() NodeType {
return t
}
const (
// NodeProgram is the program node
NodeProgram NodeType = iota
// NodeMustache is the mustache statement node
NodeMustache
// NodeBlock is the block statement node
NodeBlock
// NodePartial is the partial statement node
NodePartial
// NodeContent is the content statement node
NodeContent
// NodeComment is the comment statement node
NodeComment
// NodeExpression is the expression node
NodeExpression
// NodeSubExpression is the subexpression node
NodeSubExpression
// NodePath is the expression path node
NodePath
// NodeBoolean is the literal boolean node
NodeBoolean
// NodeNumber is the literal number node
NodeNumber
// NodeString is the literal string node
NodeString
// NodeHash is the hash node
NodeHash
// NodeHashPair is the hash pair node
NodeHashPair
)
// Loc represents the position of a parsed node in source file.
type Loc struct {
Pos int // Byte position
Line int // Line number
}
// Location returns itself, and permits struct includers to satisfy that part of Node interface.
func (l Loc) Location() Loc {
return l
}
// Strip describes node whitespace management.
type Strip struct {
Open bool
Close bool
OpenStandalone bool
CloseStandalone bool
InlineStandalone bool
}
// NewStrip instanciates a Strip for given open and close mustaches.
func NewStrip(openStr, closeStr string) *Strip {
return &Strip{
Open: (len(openStr) > 2) && openStr[2] == '~',
Close: (len(closeStr) > 2) && closeStr[len(closeStr)-3] == '~',
}
}
// NewStripForStr instanciates a Strip for given tag.
func NewStripForStr(str string) *Strip {
return &Strip{
Open: (len(str) > 2) && str[2] == '~',
Close: (len(str) > 2) && str[len(str)-3] == '~',
}
}
// String returns a string representation of receiver that can be used for debugging.
func (s *Strip) String() string {
return fmt.Sprintf("Open: %t, Close: %t, OpenStandalone: %t, CloseStandalone: %t, InlineStandalone: %t", s.Open, s.Close, s.OpenStandalone, s.CloseStandalone, s.InlineStandalone)
}
//
// Program
//
// Program represents a program node.
type Program struct {
NodeType
Loc
Body []Node // [ Statement ... ]
BlockParams []string
Chained bool
// whitespace management
Strip *Strip
}
// NewProgram instanciates a new program node.
func NewProgram(pos int, line int) *Program {
return &Program{
NodeType: NodeProgram,
Loc: Loc{pos, line},
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *Program) String() string {
return fmt.Sprintf("Program{Pos: %d}", node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *Program) Accept(visitor Visitor) interface{} {
return visitor.VisitProgram(node)
}
// AddStatement adds given statement to program.
func (node *Program) AddStatement(statement Node) {
node.Body = append(node.Body, statement)
}
//
// Mustache Statement
//
// MustacheStatement represents a mustache node.
type MustacheStatement struct {
NodeType
Loc
Unescaped bool
Expression *Expression
// whitespace management
Strip *Strip
}
// NewMustacheStatement instanciates a new mustache node.
func NewMustacheStatement(pos int, line int, unescaped bool) *MustacheStatement {
return &MustacheStatement{
NodeType: NodeMustache,
Loc: Loc{pos, line},
Unescaped: unescaped,
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *MustacheStatement) String() string {
return fmt.Sprintf("Mustache{Pos: %d}", node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *MustacheStatement) Accept(visitor Visitor) interface{} {
return visitor.VisitMustache(node)
}
//
// Block Statement
//
// BlockStatement represents a block node.
type BlockStatement struct {
NodeType
Loc
Expression *Expression
Program *Program
Inverse *Program
// whitespace management
OpenStrip *Strip
InverseStrip *Strip
CloseStrip *Strip
}
// NewBlockStatement instanciates a new block node.
func NewBlockStatement(pos int, line int) *BlockStatement {
return &BlockStatement{
NodeType: NodeBlock,
Loc: Loc{pos, line},
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *BlockStatement) String() string {
return fmt.Sprintf("Block{Pos: %d}", node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *BlockStatement) Accept(visitor Visitor) interface{} {
return visitor.VisitBlock(node)
}
//
// Partial Statement
//
// PartialStatement represents a partial node.
type PartialStatement struct {
NodeType
Loc
Name Node // PathExpression | SubExpression
Params []Node // [ Expression ... ]
Hash *Hash
// whitespace management
Strip *Strip
Indent string
}
// NewPartialStatement instanciates a new partial node.
func NewPartialStatement(pos int, line int) *PartialStatement {
return &PartialStatement{
NodeType: NodePartial,
Loc: Loc{pos, line},
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *PartialStatement) String() string {
return fmt.Sprintf("Partial{Name:%s, Pos:%d}", node.Name, node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *PartialStatement) Accept(visitor Visitor) interface{} {
return visitor.VisitPartial(node)
}
//
// Content Statement
//
// ContentStatement represents a content node.
type ContentStatement struct {
NodeType
Loc
Value string
Original string
// whitespace management
RightStripped bool
LeftStripped bool
}
// NewContentStatement instanciates a new content node.
func NewContentStatement(pos int, line int, val string) *ContentStatement {
return &ContentStatement{
NodeType: NodeContent,
Loc: Loc{pos, line},
Value: val,
Original: val,
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *ContentStatement) String() string {
return fmt.Sprintf("Content{Value:'%s', Pos:%d}", node.Value, node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *ContentStatement) Accept(visitor Visitor) interface{} {
return visitor.VisitContent(node)
}
//
// Comment Statement
//
// CommentStatement represents a comment node.
type CommentStatement struct {
NodeType
Loc
Value string
// whitespace management
Strip *Strip
}
// NewCommentStatement instanciates a new comment node.
func NewCommentStatement(pos int, line int, val string) *CommentStatement {
return &CommentStatement{
NodeType: NodeComment,
Loc: Loc{pos, line},
Value: val,
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *CommentStatement) String() string {
return fmt.Sprintf("Comment{Value:'%s', Pos:%d}", node.Value, node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *CommentStatement) Accept(visitor Visitor) interface{} {
return visitor.VisitComment(node)
}
//
// Expression
//
// Expression represents an expression node.
type Expression struct {
NodeType
Loc
Path Node // PathExpression | StringLiteral | BooleanLiteral | NumberLiteral
Params []Node // [ Expression ... ]
Hash *Hash
}
// NewExpression instanciates a new expression node.
func NewExpression(pos int, line int) *Expression {
return &Expression{
NodeType: NodeExpression,
Loc: Loc{pos, line},
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *Expression) String() string {
return fmt.Sprintf("Expr{Path:%s, Pos:%d}", node.Path, node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *Expression) Accept(visitor Visitor) interface{} {
return visitor.VisitExpression(node)
}
// HelperName returns helper name, or an empty string if this expression can't be a helper.
func (node *Expression) HelperName() string {
path, ok := node.Path.(*PathExpression)
if !ok {
return ""
}
if path.Data || (len(path.Parts) != 1) || (path.Depth > 0) || path.Scoped {
return ""
}
return path.Parts[0]
}
// FieldPath returns path expression representing a field path, or nil if this is not a field path.
func (node *Expression) FieldPath() *PathExpression {
path, ok := node.Path.(*PathExpression)
if !ok {
return nil
}
return path
}
// LiteralStr returns the string representation of literal value, with a boolean set to false if this is not a literal.
func (node *Expression) LiteralStr() (string, bool) {
return LiteralStr(node.Path)
}
// Canonical returns the canonical form of expression node as a string.
func (node *Expression) Canonical() string {
if str, ok := HelperNameStr(node.Path); ok {
return str
}
return ""
}
// HelperNameStr returns the string representation of a helper name, with a boolean set to false if this is not a valid helper name.
//
// helperName : path | dataName | STRING | NUMBER | BOOLEAN | UNDEFINED | NULL
func HelperNameStr(node Node) (string, bool) {
// PathExpression
if str, ok := PathExpressionStr(node); ok {
return str, ok
}
// Literal
if str, ok := LiteralStr(node); ok {
return str, ok
}
return "", false
}
// PathExpressionStr returns the string representation of path expression value, with a boolean set to false if this is not a path expression.
func PathExpressionStr(node Node) (string, bool) {
if path, ok := node.(*PathExpression); ok {
result := path.Original
// "[foo bar]"" => "foo bar"
if (len(result) >= 2) && (result[0] == '[') && (result[len(result)-1] == ']') {
result = result[1 : len(result)-1]
}
return result, true
}
return "", false
}
// LiteralStr returns the string representation of literal value, with a boolean set to false if this is not a literal.
func LiteralStr(node Node) (string, bool) {
if lit, ok := node.(*StringLiteral); ok {
return lit.Value, true
}
if lit, ok := node.(*BooleanLiteral); ok {
return lit.Canonical(), true
}
if lit, ok := node.(*NumberLiteral); ok {
return lit.Canonical(), true
}
return "", false
}
//
// SubExpression
//
// SubExpression represents a subexpression node.
type SubExpression struct {
NodeType
Loc
Expression *Expression
}
// NewSubExpression instanciates a new subexpression node.
func NewSubExpression(pos int, line int) *SubExpression {
return &SubExpression{
NodeType: NodeSubExpression,
Loc: Loc{pos, line},
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *SubExpression) String() string {
return fmt.Sprintf("Sexp{Path:%s, Pos:%d}", node.Expression.Path, node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *SubExpression) Accept(visitor Visitor) interface{} {
return visitor.VisitSubExpression(node)
}
//
// Path Expression
//
// PathExpression represents a path expression node.
type PathExpression struct {
NodeType
Loc
Original string
Depth int
Parts []string
Data bool
Scoped bool
}
// NewPathExpression instanciates a new path expression node.
func NewPathExpression(pos int, line int, data bool) *PathExpression {
result := &PathExpression{
NodeType: NodePath,
Loc: Loc{pos, line},
Data: data,
}
if data {
result.Original = "@"
}
return result
}
// String returns a string representation of receiver that can be used for debugging.
func (node *PathExpression) String() string {
return fmt.Sprintf("Path{Original:'%s', Pos:%d}", node.Original, node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *PathExpression) Accept(visitor Visitor) interface{} {
return visitor.VisitPath(node)
}
// Part adds path part.
func (node *PathExpression) Part(part string) {
node.Original += part
switch part {
case "..":
node.Depth++
node.Scoped = true
case ".", "this":
node.Scoped = true
default:
node.Parts = append(node.Parts, part)
}
}
// Sep adds path separator.
func (node *PathExpression) Sep(separator string) {
node.Original += separator
}
// IsDataRoot returns true if path expression is @root.
func (node *PathExpression) IsDataRoot() bool {
return node.Data && (node.Parts[0] == "root")
}
//
// String Literal
//
// StringLiteral represents a string node.
type StringLiteral struct {
NodeType
Loc
Value string
}
// NewStringLiteral instanciates a new string node.
func NewStringLiteral(pos int, line int, val string) *StringLiteral {
return &StringLiteral{
NodeType: NodeString,
Loc: Loc{pos, line},
Value: val,
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *StringLiteral) String() string {
return fmt.Sprintf("String{Value:'%s', Pos:%d}", node.Value, node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *StringLiteral) Accept(visitor Visitor) interface{} {
return visitor.VisitString(node)
}
//
// Boolean Literal
//
// BooleanLiteral represents a boolean node.
type BooleanLiteral struct {
NodeType
Loc
Value bool
Original string
}
// NewBooleanLiteral instanciates a new boolean node.
func NewBooleanLiteral(pos int, line int, val bool, original string) *BooleanLiteral {
return &BooleanLiteral{
NodeType: NodeBoolean,
Loc: Loc{pos, line},
Value: val,
Original: original,
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *BooleanLiteral) String() string {
return fmt.Sprintf("Boolean{Value:%s, Pos:%d}", node.Canonical(), node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *BooleanLiteral) Accept(visitor Visitor) interface{} {
return visitor.VisitBoolean(node)
}
// Canonical returns the canonical form of boolean node as a string (ie. "true" | "false").
func (node *BooleanLiteral) Canonical() string {
if node.Value {
return "true"
}
return "false"
}
//
// Number Literal
//
// NumberLiteral represents a number node.
type NumberLiteral struct {
NodeType
Loc
Value float64
IsInt bool
Original string
}
// NewNumberLiteral instanciates a new number node.
func NewNumberLiteral(pos int, line int, val float64, isInt bool, original string) *NumberLiteral {
return &NumberLiteral{
NodeType: NodeNumber,
Loc: Loc{pos, line},
Value: val,
IsInt: isInt,
Original: original,
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *NumberLiteral) String() string {
return fmt.Sprintf("Number{Value:%s, Pos:%d}", node.Canonical(), node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *NumberLiteral) Accept(visitor Visitor) interface{} {
return visitor.VisitNumber(node)
}
// Canonical returns the canonical form of number node as a string (eg: "12", "-1.51").
func (node *NumberLiteral) Canonical() string {
prec := -1
if node.IsInt {
prec = 0
}
return strconv.FormatFloat(node.Value, 'f', prec, 64)
}
// Number returns an integer or a float.
func (node *NumberLiteral) Number() interface{} {
if node.IsInt {
return int(node.Value)
}
return node.Value
}
//
// Hash
//
// Hash represents a hash node.
type Hash struct {
NodeType
Loc
Pairs []*HashPair
}
// NewHash instanciates a new hash node.
func NewHash(pos int, line int) *Hash {
return &Hash{
NodeType: NodeHash,
Loc: Loc{pos, line},
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *Hash) String() string {
result := fmt.Sprintf("Hash{[%d", node.Loc.Pos)
for i, p := range node.Pairs {
if i > 0 {
result += ", "
}
result += p.String()
}
return result + fmt.Sprintf("], Pos:%d}", node.Loc.Pos)
}
// Accept is the receiver entry point for visitors.
func (node *Hash) Accept(visitor Visitor) interface{} {
return visitor.VisitHash(node)
}
//
// HashPair
//
// HashPair represents a hash pair node.
type HashPair struct {
NodeType
Loc
Key string
Val Node // Expression
}
// NewHashPair instanciates a new hash pair node.
func NewHashPair(pos int, line int) *HashPair {
return &HashPair{
NodeType: NodeHashPair,
Loc: Loc{pos, line},
}
}
// String returns a string representation of receiver that can be used for debugging.
func (node *HashPair) String() string {
return node.Key + "=" + node.Val.String()
}
// Accept is the receiver entry point for visitors.
func (node *HashPair) Accept(visitor Visitor) interface{} {
return visitor.VisitHashPair(node)
}

279
ast/print.go Normal file
View File

@ -0,0 +1,279 @@
package ast
import (
"fmt"
"strings"
)
// printVisitor implements the Visitor interface to print a AST.
type printVisitor struct {
buf string
depth int
original bool
inBlock bool
}
func newPrintVisitor() *printVisitor {
return &printVisitor{}
}
// Print returns a string representation of given AST, that can be used for debugging purpose.
func Print(node Node) string {
visitor := newPrintVisitor()
node.Accept(visitor)
return visitor.output()
}
func (v *printVisitor) output() string {
return v.buf
}
func (v *printVisitor) indent() {
for i := 0; i < v.depth; {
v.buf += " "
i++
}
}
func (v *printVisitor) str(val string) {
v.buf += val
}
func (v *printVisitor) nl() {
v.str("\n")
}
func (v *printVisitor) line(val string) {
v.indent()
v.str(val)
v.nl()
}
//
// Visitor interface
//
// Statements
// VisitProgram implements corresponding Visitor interface method
func (v *printVisitor) VisitProgram(node *Program) interface{} {
if len(node.BlockParams) > 0 {
v.line("BLOCK PARAMS: [ " + strings.Join(node.BlockParams, " ") + " ]")
}
for _, n := range node.Body {
n.Accept(v)
}
return nil
}
// VisitMustache implements corresponding Visitor interface method
func (v *printVisitor) VisitMustache(node *MustacheStatement) interface{} {
v.indent()
v.str("{{ ")
node.Expression.Accept(v)
v.str(" }}")
v.nl()
return nil
}
// VisitBlock implements corresponding Visitor interface method
func (v *printVisitor) VisitBlock(node *BlockStatement) interface{} {
v.inBlock = true
v.line("BLOCK:")
v.depth++
node.Expression.Accept(v)
if node.Program != nil {
v.line("PROGRAM:")
v.depth++
node.Program.Accept(v)
v.depth--
}
if node.Inverse != nil {
// if node.Program != nil {
// v.depth++
// }
v.line("{{^}}")
v.depth++
node.Inverse.Accept(v)
v.depth--
// if node.Program != nil {
// v.depth--
// }
}
v.inBlock = false
return nil
}
// VisitPartial implements corresponding Visitor interface method
func (v *printVisitor) VisitPartial(node *PartialStatement) interface{} {
v.indent()
v.str("{{> PARTIAL:")
v.original = true
node.Name.Accept(v)
v.original = false
if len(node.Params) > 0 {
v.str(" ")
node.Params[0].Accept(v)
}
// hash
if node.Hash != nil {
v.str(" ")
node.Hash.Accept(v)
}
v.str(" }}")
v.nl()
return nil
}
// VisitContent implements corresponding Visitor interface method
func (v *printVisitor) VisitContent(node *ContentStatement) interface{} {
v.line("CONTENT[ '" + node.Value + "' ]")
return nil
}
// VisitComment implements corresponding Visitor interface method
func (v *printVisitor) VisitComment(node *CommentStatement) interface{} {
v.line("{{! '" + node.Value + "' }}")
return nil
}
// Expressions
// VisitExpression implements corresponding Visitor interface method
func (v *printVisitor) VisitExpression(node *Expression) interface{} {
if v.inBlock {
v.indent()
}
// path
node.Path.Accept(v)
// params
v.str(" [")
for i, n := range node.Params {
if i > 0 {
v.str(", ")
}
n.Accept(v)
}
v.str("]")
// hash
if node.Hash != nil {
v.str(" ")
node.Hash.Accept(v)
}
if v.inBlock {
v.nl()
}
return nil
}
// VisitSubExpression implements corresponding Visitor interface method
func (v *printVisitor) VisitSubExpression(node *SubExpression) interface{} {
node.Expression.Accept(v)
return nil
}
// VisitPath implements corresponding Visitor interface method
func (v *printVisitor) VisitPath(node *PathExpression) interface{} {
if v.original {
v.str(node.Original)
} else {
path := strings.Join(node.Parts, "/")
result := ""
if node.Data {
result += "@"
}
v.str(result + "PATH:" + path)
}
return nil
}
// Literals
// VisitString implements corresponding Visitor interface method
func (v *printVisitor) VisitString(node *StringLiteral) interface{} {
if v.original {
v.str(node.Value)
} else {
v.str("\"" + node.Value + "\"")
}
return nil
}
// VisitBoolean implements corresponding Visitor interface method
func (v *printVisitor) VisitBoolean(node *BooleanLiteral) interface{} {
if v.original {
v.str(node.Original)
} else {
v.str(fmt.Sprintf("BOOLEAN{%s}", node.Canonical()))
}
return nil
}
// VisitNumber implements corresponding Visitor interface method
func (v *printVisitor) VisitNumber(node *NumberLiteral) interface{} {
if v.original {
v.str(node.Original)
} else {
v.str(fmt.Sprintf("NUMBER{%s}", node.Canonical()))
}
return nil
}
// Miscellaneous
// VisitHash implements corresponding Visitor interface method
func (v *printVisitor) VisitHash(node *Hash) interface{} {
v.str("HASH{")
for i, p := range node.Pairs {
if i > 0 {
v.str(", ")
}
p.Accept(v)
}
v.str("}")
return nil
}
// VisitHashPair implements corresponding Visitor interface method
func (v *printVisitor) VisitHashPair(node *HashPair) interface{} {
v.str(node.Key + "=")
node.Val.Accept(v)
return nil
}

167
base_test.go Normal file
View File

@ -0,0 +1,167 @@
package handlebars
import (
"fmt"
"regexp"
"testing"
)
type Test struct {
name string
input string
data interface{}
privData map[string]interface{}
helpers map[string]interface{}
partials map[string]string
output interface{}
}
func launchTests(t *testing.T, tests []Test) {
// NOTE: TestMustache() makes Parallel testing fail
// t.Parallel()
for _, test := range tests {
var err error
var tpl *Template
// parse template
tpl, err = Parse(test.input)
if err != nil {
t.Errorf("Test '%s' failed - Failed to parse template\ninput:\n\t'%s'\nerror:\n\t%s", test.name, test.input, err)
} else {
if len(test.helpers) > 0 {
// register helpers
tpl.RegisterHelpers(test.helpers)
}
if len(test.partials) > 0 {
// register partials
tpl.RegisterPartials(test.partials)
}
// setup private data frame
var privData *DataFrame
if test.privData != nil {
privData = NewDataFrame()
for k, v := range test.privData {
privData.Set(k, v)
}
}
// render template
output, err := tpl.ExecWith(test.data, privData)
if err != nil {
t.Errorf("Test '%s' failed\ninput:\n\t'%s'\ndata:\n\t%s\nerror:\n\t%s\nAST:\n\t%s", test.name, test.input, Str(test.data), err, tpl.PrintAST())
} else {
// check output
var expectedArr []string
expectedArr, ok := test.output.([]string)
if ok {
match := false
for _, expectedStr := range expectedArr {
if expectedStr == output {
match = true
break
}
}
if !match {
t.Errorf("Test '%s' failed\ninput:\n\t'%s'\ndata:\n\t%s\npartials:\n\t%s\nexpected\n\t%q\ngot\n\t%q\nAST:\n%s", test.name, test.input, Str(test.data), Str(test.partials), expectedArr, output, tpl.PrintAST())
}
} else {
expectedStr, ok := test.output.(string)
if !ok {
panic(fmt.Errorf("Erroneous test output description: %q", test.output))
}
if expectedStr != output {
t.Errorf("Test '%s' failed\ninput:\n\t'%s'\ndata:\n\t%s\npartials:\n\t%s\nexpected\n\t%q\ngot\n\t%q\nAST:\n%s", test.name, test.input, Str(test.data), Str(test.partials), expectedStr, output, tpl.PrintAST())
}
}
}
}
}
}
func launchErrorTests(t *testing.T, tests []Test) {
t.Parallel()
for _, test := range tests {
var err error
var tpl *Template
// parse template
tpl, err = Parse(test.input)
if err != nil {
t.Errorf("Test '%s' failed - Failed to parse template\ninput:\n\t'%s'\nerror:\n\t%s", test.name, test.input, err)
} else {
if len(test.helpers) > 0 {
// register helpers
tpl.RegisterHelpers(test.helpers)
}
if len(test.partials) > 0 {
// register partials
tpl.RegisterPartials(test.partials)
}
// setup private data frame
var privData *DataFrame
if test.privData != nil {
privData := NewDataFrame()
for k, v := range test.privData {
privData.Set(k, v)
}
}
// render template
output, err := tpl.ExecWith(test.data, privData)
if err == nil {
t.Errorf("Test '%s' failed - Error expected\ninput:\n\t'%s'\ngot\n\t%q\nAST:\n%q", test.name, test.input, output, tpl.PrintAST())
} else {
var errMatch error
match := false
// check output
var expectedArr []string
expectedArr, ok := test.output.([]string)
if ok {
if len(expectedArr) > 0 {
for _, expectedStr := range expectedArr {
match, errMatch = regexp.MatchString(regexp.QuoteMeta(expectedStr), fmt.Sprint(err))
if errMatch != nil {
panic("Failed to match regexp")
}
if match {
break
}
}
} else {
// nothing to test
match = true
}
} else {
expectedStr, ok := test.output.(string)
if !ok {
panic(fmt.Errorf("Erroneous test output description: %q", test.output))
}
if expectedStr != "" {
match, errMatch = regexp.MatchString(regexp.QuoteMeta(expectedStr), fmt.Sprint(err))
if errMatch != nil {
panic("Failed to match regexp")
}
} else {
// nothing to test
match = true
}
}
if !match {
t.Errorf("Test '%s' failed - Incorrect error returned\ninput:\n\t'%s'\ndata:\n\t%s\nexpected\n\t%q\ngot\n\t%q", test.name, test.input, Str(test.data), test.output, err)
}
}
}
}
}

316
benchmark_test.go Normal file
View File

@ -0,0 +1,316 @@
package handlebars
import "testing"
//
// Those tests come from:
// https://github.com/wycats/handlebars.js/blob/master/bench/
//
// Note that handlebars.js does NOT benchmark template compilation, it only benchmarks evaluation.
//
func BenchmarkArguments(b *testing.B) {
source := `{{foo person "person" 1 true foo=bar foo="person" foo=1 foo=true}}`
ctx := map[string]bool{
"bar": true,
}
tpl := MustParse(source)
tpl.RegisterHelper("foo", func(a, b, c, d interface{}) string { return "" })
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkArrayEach(b *testing.B) {
source := `{{#each names}}{{name}}{{/each}}`
ctx := map[string][]map[string]string{
"names": {
{"name": "Moe"},
{"name": "Larry"},
{"name": "Curly"},
{"name": "Shemp"},
},
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkArrayMustache(b *testing.B) {
source := `{{#names}}{{name}}{{/names}}`
ctx := map[string][]map[string]string{
"names": {
{"name": "Moe"},
{"name": "Larry"},
{"name": "Curly"},
{"name": "Shemp"},
},
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkComplex(b *testing.B) {
source := `<h1>{{header}}</h1>
{{#if items}}
<ul>
{{#each items}}
{{#if current}}
<li><strong>{{name}}</strong></li>
{{^}}
<li><a href="{{url}}">{{name}}</a></li>
{{/if}}
{{/each}}
</ul>
{{^}}
<p>The list is empty.</p>
{{/if}}
`
ctx := map[string]interface{}{
"header": func() string { return "Colors" },
"hasItems": true,
"items": []map[string]interface{}{
{"name": "red", "current": true, "url": "#Red"},
{"name": "green", "current": false, "url": "#Green"},
{"name": "blue", "current": false, "url": "#Blue"},
},
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkData(b *testing.B) {
source := `{{#each names}}{{@index}}{{name}}{{/each}}`
ctx := map[string][]map[string]string{
"names": {
{"name": "Moe"},
{"name": "Larry"},
{"name": "Curly"},
{"name": "Shemp"},
},
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkDepth1(b *testing.B) {
source := `{{#each names}}{{../foo}}{{/each}}`
ctx := map[string]interface{}{
"names": []map[string]string{
{"name": "Moe"},
{"name": "Larry"},
{"name": "Curly"},
{"name": "Shemp"},
},
"foo": "bar",
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkDepth2(b *testing.B) {
source := `{{#each names}}{{#each name}}{{../bat}}{{../../foo}}{{/each}}{{/each}}`
ctx := map[string]interface{}{
"names": []map[string]interface{}{
{"bat": "foo", "name": []string{"Moe"}},
{"bat": "foo", "name": []string{"Larry"}},
{"bat": "foo", "name": []string{"Curly"}},
{"bat": "foo", "name": []string{"Shemp"}},
},
"foo": "bar",
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkObjectMustache(b *testing.B) {
source := `{{#person}}{{name}}{{age}}{{/person}}`
ctx := map[string]interface{}{
"person": map[string]interface{}{
"name": "Larry",
"age": 45,
},
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkObject(b *testing.B) {
source := `{{#with person}}{{name}}{{age}}{{/with}}`
ctx := map[string]interface{}{
"person": map[string]interface{}{
"name": "Larry",
"age": 45,
},
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkPartialRecursion(b *testing.B) {
source := `{{name}}{{#each kids}}{{>recursion}}{{/each}}`
ctx := map[string]interface{}{
"name": 1,
"kids": []map[string]interface{}{
{
"name": "1.1",
"kids": []map[string]interface{}{
{
"name": "1.1.1",
"kids": []map[string]interface{}{},
},
},
},
},
}
tpl := MustParse(source)
partial := MustParse(`{{name}}{{#each kids}}{{>recursion}}{{/each}}`)
tpl.RegisterPartialTemplate("recursion", partial)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkPartial(b *testing.B) {
source := `{{#each peeps}}{{>variables}}{{/each}}`
ctx := map[string]interface{}{
"peeps": []map[string]interface{}{
{"name": "Moe", "count": 15},
{"name": "Moe", "count": 5},
{"name": "Curly", "count": 1},
},
}
tpl := MustParse(source)
partial := MustParse(`Hello {{name}}! You have {{count}} new messages.`)
tpl.RegisterPartialTemplate("variables", partial)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkPath(b *testing.B) {
source := `{{person.name.bar.baz}}{{person.age}}{{person.foo}}{{animal.age}}`
ctx := map[string]interface{}{
"person": map[string]interface{}{
"name": map[string]interface{}{
"bar": map[string]string{
"baz": "Larry",
},
},
"age": 45,
},
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkString(b *testing.B) {
source := `Hello world`
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(nil)
}
}
func BenchmarkSubExpression(b *testing.B) {
source := `{{echo (header)}}`
ctx := map[string]interface{}{}
tpl := MustParse(source)
tpl.RegisterHelpers(map[string]interface{}{
"echo": func(v string) string { return "foo " + v },
"header": func() string { return "Colors" },
})
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}
func BenchmarkVariables(b *testing.B) {
source := `Hello {{name}}! You have {{count}} new messages.`
ctx := map[string]interface{}{
"name": "Mick",
"count": 30,
}
tpl := MustParse(source)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.MustExec(ctx)
}
}

95
data_frame.go Normal file
View File

@ -0,0 +1,95 @@
package handlebars
import "reflect"
// DataFrame represents a private data frame.
//
// Cf. private variables documentation at: http://handlebarsjs.com/block_helpers.html
type DataFrame struct {
parent *DataFrame
data map[string]interface{}
}
// NewDataFrame instanciates a new private data frame.
func NewDataFrame() *DataFrame {
return &DataFrame{
data: make(map[string]interface{}),
}
}
// Copy instanciates a new private data frame with receiver as parent.
func (p *DataFrame) Copy() *DataFrame {
result := NewDataFrame()
for k, v := range p.data {
result.data[k] = v
}
result.parent = p
return result
}
// newIterDataFrame instanciates a new private data frame with receiver as parent and with iteration data set (@index, @key, @first, @last)
func (p *DataFrame) newIterDataFrame(length int, i int, key interface{}) *DataFrame {
result := p.Copy()
result.Set("index", i)
result.Set("key", key)
result.Set("first", i == 0)
result.Set("last", i == length-1)
return result
}
// Set sets a data value.
func (p *DataFrame) Set(key string, val interface{}) {
p.data[key] = val
}
// Get gets a data value.
func (p *DataFrame) Get(key string) interface{} {
return p.find([]string{key})
}
// find gets a deep data value
//
// @todo This is NOT consistent with the way we resolve data in template (cf. `evalDataPathExpression()`) ! FIX THAT !
func (p *DataFrame) find(parts []string) interface{} {
data := p.data
for i, part := range parts {
val := data[part]
if val == nil {
return nil
}
if i == len(parts)-1 {
// found
return val
}
valValue := reflect.ValueOf(val)
if valValue.Kind() != reflect.Map {
// not found
return nil
}
// continue
data = mapStringInterface(valValue)
}
// not found
return nil
}
// mapStringInterface converts any `map` to `map[string]interface{}`
func mapStringInterface(value reflect.Value) map[string]interface{} {
result := make(map[string]interface{})
for _, key := range value.MapKeys() {
result[strValue(key)] = value.MapIndex(key).Interface()
}
return result
}

65
escape.go Normal file
View File

@ -0,0 +1,65 @@
package handlebars
import (
"bytes"
"strings"
)
//
// That whole file is borrowed from https://github.com/golang/go/tree/master/src/html/escape.go
//
// With changes:
// &#39 => &apos;
// &#34 => &quot;
//
// To stay in sync with JS implementation, and make mustache tests pass.
//
type writer interface {
WriteString(string) (int, error)
}
const escapedChars = `&'<>"`
func escape(w writer, s string) error {
i := strings.IndexAny(s, escapedChars)
for i != -1 {
if _, err := w.WriteString(s[:i]); err != nil {
return err
}
var esc string
switch s[i] {
case '&':
esc = "&amp;"
case '\'':
esc = "&apos;"
case '<':
esc = "&lt;"
case '>':
esc = "&gt;"
case '"':
esc = "&quot;"
default:
panic("unrecognized escape character")
}
s = s[i+1:]
if _, err := w.WriteString(esc); err != nil {
return err
}
i = strings.IndexAny(s, escapedChars)
}
_, err := w.WriteString(s)
return err
}
// Escape escapes special HTML characters.
//
// It can be used by helpers that return a SafeString and that need to escape some content by themselves.
func Escape(s string) string {
if strings.IndexAny(s, escapedChars) == -1 {
return s
}
var buf bytes.Buffer
escape(&buf, s)
return buf.String()
}

20
escape_test.go Normal file
View File

@ -0,0 +1,20 @@
package handlebars
import "fmt"
func ExampleEscape() {
tpl := MustParse("{{link url text}}")
tpl.RegisterHelper("link", func(url string, text string) SafeString {
return SafeString("<a href='" + Escape(url) + "'>" + Escape(text) + "</a>")
})
ctx := map[string]string{
"url": "http://www.aymerick.com/",
"text": "This is a <em>cool</em> website",
}
result := tpl.MustExec(ctx)
fmt.Print(result)
// Output: <a href='http://www.aymerick.com/'>This is a &lt;em&gt;cool&lt;/em&gt; website</a>
}

1009
eval.go Normal file

File diff suppressed because it is too large Load Diff

297
eval_test.go Normal file
View File

@ -0,0 +1,297 @@
package handlebars
import "testing"
var evalTests = []Test{
{
"only content",
"this is content",
nil, nil, nil, nil,
"this is content",
},
{
"checks path in parent contexts",
"{{#a}}{{one}}{{#b}}{{one}}{{two}}{{one}}{{/b}}{{/a}}",
map[string]interface{}{"a": map[string]int{"one": 1}, "b": map[string]int{"two": 2}},
nil, nil, nil,
"1121",
},
{
"block params",
"{{#foo as |bar|}}{{bar}}{{/foo}}{{bar}}",
map[string]string{"foo": "baz", "bar": "bat"},
nil, nil, nil,
"bazbat",
},
{
"block params on array",
"{{#foo as |bar i|}}{{i}}.{{bar}} {{/foo}}",
map[string][]string{"foo": {"baz", "bar", "bat"}},
nil, nil, nil,
"0.baz 1.bar 2.bat ",
},
{
"nested block params",
"{{#foos as |foo iFoo|}}{{#wats as |wat iWat|}}{{iFoo}}.{{iWat}}.{{foo}}-{{wat}} {{/wats}}{{/foos}}",
map[string][]string{"foos": {"baz", "bar"}, "wats": {"the", "phoque"}},
nil, nil, nil,
"0.0.baz-the 0.1.baz-phoque 1.0.bar-the 1.1.bar-phoque ",
},
{
"block params with path reference",
"{{#foo as |bar|}}{{bar.baz}}{{/foo}}",
map[string]map[string]string{"foo": {"baz": "bat"}},
nil, nil, nil,
"bat",
},
{
"falsy block evaluation",
"{{#foo}}bar{{/foo}} baz",
map[string]interface{}{"foo": false},
nil, nil, nil,
" baz",
},
{
"block helper returns a SafeString",
"{{title}} - {{#bold}}{{body}}{{/bold}}",
map[string]string{
"title": "My new blog post",
"body": "I have so many things to say!",
},
nil,
map[string]interface{}{"bold": func(options *Options) SafeString {
return SafeString(`<div class="mybold">` + options.Fn() + "</div>")
}},
nil,
`My new blog post - <div class="mybold">I have so many things to say!</div>`,
},
{
"chained blocks",
"{{#if a}}A{{else if b}}B{{else}}C{{/if}}",
map[string]interface{}{"b": false},
nil, nil, nil,
"C",
},
{
"virtual length method on a map",
"Length: {{map.length}}",
map[string]interface{}{"map": map[string]string{"a": "a", "b": "b"}},
nil, nil, nil,
`Length: 2`,
},
{
"virtual length method on a slice",
"Length: {{arr.length}}",
map[string]interface{}{"arr": []int{0, 1, 2}},
nil, nil, nil,
`Length: 3`,
},
{
"virtual length method on an array",
"Length: {{arr.length}}",
map[string]interface{}{"arr": [...]int{0, 1, 2, 3}},
nil, nil, nil,
`Length: 4`,
},
{
"virtual length method on a string",
"Length: {{str.length}}",
map[string]interface{}{"str": "abcde"},
nil, nil, nil,
`Length: 5`,
},
// @todo Test with a "../../path" (depth 2 path) while context is only depth 1
}
func TestEval(t *testing.T) {
t.Parallel()
launchTests(t, evalTests)
}
var evalErrors = []Test{
{
"functions with wrong number of arguments",
`{{foo "bar"}}`,
map[string]interface{}{"foo": func(a string, b string) string { return "foo" }},
nil, nil, nil,
"Helper 'foo' called with wrong number of arguments, needed 2 but got 1",
},
{
"functions with wrong number of returned values (1)",
"{{foo}}",
map[string]interface{}{"foo": func() {}},
nil, nil, nil,
"Helper function must return a string or a SafeString",
},
{
"functions with wrong number of returned values (2)",
"{{foo}}",
map[string]interface{}{"foo": func() (string, bool, string) { return "foo", true, "bar" }},
nil, nil, nil,
"Helper function must return a string or a SafeString",
},
}
func TestEvalErrors(t *testing.T) {
launchErrorTests(t, evalErrors)
}
func TestEvalStruct(t *testing.T) {
t.Parallel()
source := `<div class="post">
<h1>By {{author.FirstName}} {{Author.lastName}}</h1>
<div class="body">{{Body}}</div>
<h1>Comments</h1>
{{#each comments}}
<h2>By {{Author.FirstName}} {{author.LastName}}</h2>
<div class="body">{{body}}</div>
{{/each}}
</div>`
expected := `<div class="post">
<h1>By Jean Valjean</h1>
<div class="body">Life is difficult</div>
<h1>Comments</h1>
<h2>By Marcel Beliveau</h2>
<div class="body">LOL!</div>
</div>`
type Person struct {
FirstName string
LastName string
}
type Comment struct {
Author Person
Body string
}
type Post struct {
Author Person
Body string
Comments []Comment
}
ctx := Post{
Person{"Jean", "Valjean"},
"Life is difficult",
[]Comment{
Comment{
Person{"Marcel", "Beliveau"},
"LOL!",
},
},
}
output := MustRender(source, ctx)
if output != expected {
t.Errorf("Failed to evaluate with struct context")
}
}
func TestEvalStructTag(t *testing.T) {
t.Parallel()
source := `<div class="person">
<h1>{{real-name}}</h1>
<ul>
<li>City: {{info.location}}</li>
<li>Rug: {{info.[r.u.g]}}</li>
<li>Activity: {{info.activity}}</li>
</ul>
{{#each other-names}}
<p>{{alias-name}}</p>
{{/each}}
</div>`
expected := `<div class="person">
<h1>Lebowski</h1>
<ul>
<li>City: Venice</li>
<li>Rug: Tied The Room Together</li>
<li>Activity: Bowling</li>
</ul>
<p>his dudeness</p>
<p>el duderino</p>
</div>`
type Alias struct {
Name string `handlebars:"alias-name"`
}
type CharacterInfo struct {
City string `handlebars:"location"`
Rug string `handlebars:"r.u.g"`
Activity string `handlebars:"not-activity"`
}
type Character struct {
RealName string `handlebars:"real-name"`
Info CharacterInfo
Aliases []Alias `handlebars:"other-names"`
}
ctx := Character{
"Lebowski",
CharacterInfo{"Venice", "Tied The Room Together", "Bowling"},
[]Alias{
{"his dudeness"},
{"el duderino"},
},
}
output := MustRender(source, ctx)
if output != expected {
t.Errorf("Failed to evaluate with struct tag context")
}
}
type TestFoo struct{}
func (t *TestFoo) Subject() string {
return "foo"
}
func TestEvalMethod(t *testing.T) {
t.Parallel()
source := `Subject is {{subject}}! YES I SAID {{Subject}}!`
expected := `Subject is foo! YES I SAID foo!`
ctx := &TestFoo{}
output := MustRender(source, ctx)
if output != expected {
t.Errorf("Failed to evaluate struct method: %s", output)
}
}
type TestBar struct{}
func (t *TestBar) Subject() interface{} {
return testBar
}
func testBar() string {
return "bar"
}
func TestEvalMethodReturningFunc(t *testing.T) {
t.Parallel()
source := `Subject is {{subject}}! YES I SAID {{Subject}}!`
expected := `Subject is bar! YES I SAID bar!`
ctx := &TestBar{}
output := MustRender(source, ctx)
if output != expected {
t.Errorf("Failed to evaluate struct method: %s", output)
}
}

119
example_test.go Normal file
View File

@ -0,0 +1,119 @@
package handlebars_test
import (
"fmt"
"git.reinaldyrafli.com/aldy505/handlebars-go"
)
func Example() {
source := "<h1>{{title}}</h1><p>{{body.content}}</p>"
ctx := map[string]interface{}{
"title": "foo",
"body": map[string]string{"content": "bar"},
}
// parse template
tpl := handlebars.MustParse(source)
// evaluate template with context
output := tpl.MustExec(ctx)
// alternatively, for one shots:
// output := MustRender(source, ctx)
fmt.Print(output)
// Output: <h1>foo</h1><p>bar</p>
}
func Example_struct() {
source := `<div class="post">
<h1>By {{fullName author}}</h1>
<div class="body">{{body}}</div>
<h1>Comments</h1>
{{#each comments}}
<h2>By {{fullName author}}</h2>
<div class="body">{{content}}</div>
{{/each}}
</div>`
type Person struct {
FirstName string
LastName string
}
type Comment struct {
Author Person
Body string `handlebars:"content"`
}
type Post struct {
Author Person
Body string
Comments []Comment
}
ctx := Post{
Person{"Jean", "Valjean"},
"Life is difficult",
[]Comment{
{
Person{"Marcel", "Beliveau"},
"LOL!",
},
},
}
handlebars.RegisterHelper("fullName", func(person Person) string {
return person.FirstName + " " + person.LastName
})
output := handlebars.MustRender(source, ctx)
fmt.Print(output)
// Output: <div class="post">
// <h1>By Jean Valjean</h1>
// <div class="body">Life is difficult</div>
//
// <h1>Comments</h1>
//
// <h2>By Marcel Beliveau</h2>
// <div class="body">LOL!</div>
// </div>
}
func ExampleRender() {
tpl := "<h1>{{title}}</h1><p>{{body.content}}</p>"
ctx := map[string]interface{}{
"title": "foo",
"body": map[string]string{"content": "bar"},
}
// render template with context
output, err := handlebars.Render(tpl, ctx)
if err != nil {
panic(err)
}
fmt.Print(output)
// Output: <h1>foo</h1><p>bar</p>
}
func ExampleMustRender() {
tpl := "<h1>{{title}}</h1><p>{{body.content}}</p>"
ctx := map[string]interface{}{
"title": "foo",
"body": map[string]string{"content": "bar"},
}
// render template with context
output := handlebars.MustRender(tpl, ctx)
fmt.Print(output)
// Output: <h1>foo</h1><p>bar</p>
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module git.reinaldyrafli.com/aldy505/handlebars-go
go 1.20
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

BIN
handlebars-gopher.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

28
handlebars.go Normal file
View File

@ -0,0 +1,28 @@
// Package handlebars provides handlebars evaluation
package handlebars
// Render parses a template and evaluates it with given context
//
// Note that this function call is not optimal as your template is parsed everytime you call it. You should use Parse() function instead.
func Render(source string, ctx interface{}) (string, error) {
// parse template
tpl, err := Parse(source)
if err != nil {
return "", err
}
// renders template
str, err := tpl.Exec(ctx)
if err != nil {
return "", err
}
return str, nil
}
// MustRender parses a template and evaluates it with given context. It panics on error.
//
// Note that this function call is not optimal as your template is parsed everytime you call it. You should use Parse() function instead.
func MustRender(source string, ctx interface{}) string {
return MustParse(source).MustExec(ctx)
}

398
helper.go Normal file
View File

@ -0,0 +1,398 @@
package handlebars
import (
"fmt"
"log"
"reflect"
"sync"
)
// Options represents the options argument provided to helpers and context functions.
type Options struct {
// evaluation visitor
eval *evalVisitor
// params
params []interface{}
hash map[string]interface{}
}
// helpers stores all globally registered helpers
var helpers = make(map[string]reflect.Value)
// protects global helpers
var helpersMutex sync.RWMutex
func init() {
// register builtin helpers
RegisterHelper("if", ifHelper)
RegisterHelper("unless", unlessHelper)
RegisterHelper("with", withHelper)
RegisterHelper("each", eachHelper)
RegisterHelper("log", logHelper)
RegisterHelper("lookup", lookupHelper)
RegisterHelper("equal", equalHelper)
}
// RegisterHelper registers a global helper. That helper will be available to all templates.
func RegisterHelper(name string, helper interface{}) {
helpersMutex.Lock()
defer helpersMutex.Unlock()
if helpers[name] != zero {
panic(fmt.Errorf("Helper already registered: %s", name))
}
val := reflect.ValueOf(helper)
ensureValidHelper(name, val)
helpers[name] = val
}
// RegisterHelpers registers several global helpers. Those helpers will be available to all templates.
func RegisterHelpers(helpers map[string]interface{}) {
for name, helper := range helpers {
RegisterHelper(name, helper)
}
}
// RemoveHelper unregisters a global helper
func RemoveHelper(name string) {
helpersMutex.Lock()
defer helpersMutex.Unlock()
delete(helpers, name)
}
// RemoveAllHelpers unregisters all global helpers
func RemoveAllHelpers() {
helpersMutex.Lock()
defer helpersMutex.Unlock()
helpers = make(map[string]reflect.Value)
}
// ensureValidHelper panics if given helper is not valid
func ensureValidHelper(name string, funcValue reflect.Value) {
if funcValue.Kind() != reflect.Func {
panic(fmt.Errorf("Helper must be a function: %s", name))
}
funcType := funcValue.Type()
if funcType.NumOut() != 1 {
panic(fmt.Errorf("Helper function must return a string or a SafeString: %s", name))
}
// @todo Check if first returned value is a string, SafeString or interface{} ?
}
// findHelper finds a globally registered helper
func findHelper(name string) reflect.Value {
helpersMutex.RLock()
defer helpersMutex.RUnlock()
return helpers[name]
}
// newOptions instanciates a new Options
func newOptions(eval *evalVisitor, params []interface{}, hash map[string]interface{}) *Options {
return &Options{
eval: eval,
params: params,
hash: hash,
}
}
// newEmptyOptions instanciates a new empty Options
func newEmptyOptions(eval *evalVisitor) *Options {
return &Options{
eval: eval,
hash: make(map[string]interface{}),
}
}
//
// Context Values
//
// Value returns field value from current context.
func (options *Options) Value(name string) interface{} {
value := options.eval.evalField(options.eval.curCtx(), name, false)
if !value.IsValid() {
return nil
}
return value.Interface()
}
// ValueStr returns string representation of field value from current context.
func (options *Options) ValueStr(name string) string {
return Str(options.Value(name))
}
// Ctx returns current evaluation context.
func (options *Options) Ctx() interface{} {
return options.eval.curCtx().Interface()
}
//
// Hash Arguments
//
// HashProp returns hash property.
func (options *Options) HashProp(name string) interface{} {
return options.hash[name]
}
// HashStr returns string representation of hash property.
func (options *Options) HashStr(name string) string {
return Str(options.hash[name])
}
// Hash returns entire hash.
func (options *Options) Hash() map[string]interface{} {
return options.hash
}
//
// Parameters
//
// Param returns parameter at given position.
func (options *Options) Param(pos int) interface{} {
if len(options.params) > pos {
return options.params[pos]
}
return nil
}
// ParamStr returns string representation of parameter at given position.
func (options *Options) ParamStr(pos int) string {
return Str(options.Param(pos))
}
// Params returns all parameters.
func (options *Options) Params() []interface{} {
return options.params
}
//
// Private data
//
// Data returns private data value.
func (options *Options) Data(name string) interface{} {
return options.eval.dataFrame.Get(name)
}
// DataStr returns string representation of private data value.
func (options *Options) DataStr(name string) string {
return Str(options.eval.dataFrame.Get(name))
}
// DataFrame returns current private data frame.
func (options *Options) DataFrame() *DataFrame {
return options.eval.dataFrame
}
// NewDataFrame instanciates a new data frame that is a copy of current evaluation data frame.
//
// Parent of returned data frame is set to current evaluation data frame.
func (options *Options) NewDataFrame() *DataFrame {
return options.eval.dataFrame.Copy()
}
// newIterDataFrame instanciates a new data frame and set iteration specific vars
func (options *Options) newIterDataFrame(length int, i int, key interface{}) *DataFrame {
return options.eval.dataFrame.newIterDataFrame(length, i, key)
}
//
// Evaluation
//
// evalBlock evaluates block with given context, private data and iteration key
func (options *Options) evalBlock(ctx interface{}, data *DataFrame, key interface{}) string {
result := ""
if block := options.eval.curBlock(); (block != nil) && (block.Program != nil) {
result = options.eval.evalProgram(block.Program, ctx, data, key)
}
return result
}
// Fn evaluates block with current evaluation context.
func (options *Options) Fn() string {
return options.evalBlock(nil, nil, nil)
}
// FnCtxData evaluates block with given context and private data frame.
func (options *Options) FnCtxData(ctx interface{}, data *DataFrame) string {
return options.evalBlock(ctx, data, nil)
}
// FnWith evaluates block with given context.
func (options *Options) FnWith(ctx interface{}) string {
return options.evalBlock(ctx, nil, nil)
}
// FnData evaluates block with given private data frame.
func (options *Options) FnData(data *DataFrame) string {
return options.evalBlock(nil, data, nil)
}
// Inverse evaluates "else block".
func (options *Options) Inverse() string {
result := ""
if block := options.eval.curBlock(); (block != nil) && (block.Inverse != nil) {
result, _ = block.Inverse.Accept(options.eval).(string)
}
return result
}
// Eval evaluates field for given context.
func (options *Options) Eval(ctx interface{}, field string) interface{} {
if ctx == nil {
return nil
}
if field == "" {
return nil
}
val := options.eval.evalField(reflect.ValueOf(ctx), field, false)
if !val.IsValid() {
return nil
}
return val.Interface()
}
//
// Misc
//
// isIncludableZero returns true if 'includeZero' option is set and first param is the number 0
func (options *Options) isIncludableZero() bool {
b, ok := options.HashProp("includeZero").(bool)
if ok && b {
nb, ok := options.Param(0).(int)
if ok && nb == 0 {
return true
}
}
return false
}
//
// Builtin helpers
//
// #if block helper
func ifHelper(conditional interface{}, options *Options) interface{} {
if options.isIncludableZero() || IsTrue(conditional) {
return options.Fn()
}
return options.Inverse()
}
// #unless block helper
func unlessHelper(conditional interface{}, options *Options) interface{} {
if options.isIncludableZero() || IsTrue(conditional) {
return options.Inverse()
}
return options.Fn()
}
// #with block helper
func withHelper(context interface{}, options *Options) interface{} {
if IsTrue(context) {
return options.FnWith(context)
}
return options.Inverse()
}
// #each block helper
func eachHelper(context interface{}, options *Options) interface{} {
if !IsTrue(context) {
return options.Inverse()
}
result := ""
val := reflect.ValueOf(context)
switch val.Kind() {
case reflect.Array, reflect.Slice:
for i := 0; i < val.Len(); i++ {
// computes private data
data := options.newIterDataFrame(val.Len(), i, nil)
// evaluates block
result += options.evalBlock(val.Index(i).Interface(), data, i)
}
case reflect.Map:
// note: a go hash is not ordered, so result may vary, this behaviour differs from the JS implementation
keys := val.MapKeys()
for i := 0; i < len(keys); i++ {
key := keys[i].Interface()
ctx := val.MapIndex(keys[i]).Interface()
// computes private data
data := options.newIterDataFrame(len(keys), i, key)
// evaluates block
result += options.evalBlock(ctx, data, key)
}
case reflect.Struct:
var exportedFields []int
// collect exported fields only
for i := 0; i < val.NumField(); i++ {
if tField := val.Type().Field(i); tField.PkgPath == "" {
exportedFields = append(exportedFields, i)
}
}
for i, fieldIndex := range exportedFields {
key := val.Type().Field(fieldIndex).Name
ctx := val.Field(fieldIndex).Interface()
// computes private data
data := options.newIterDataFrame(len(exportedFields), i, key)
// evaluates block
result += options.evalBlock(ctx, data, key)
}
}
return result
}
// #log helper
func logHelper(message string) interface{} {
log.Print(message)
return ""
}
// #lookup helper
func lookupHelper(obj interface{}, field string, options *Options) interface{} {
return Str(options.Eval(obj, field))
}
// #equal helper
// Ref: https://github.com/aymerick/raymond/issues/7
func equalHelper(a interface{}, b interface{}, options *Options) interface{} {
if Str(a) == Str(b) {
return options.Fn()
}
return options.Inverse()
}

273
helper_test.go Normal file
View File

@ -0,0 +1,273 @@
package handlebars
import "testing"
const (
VERBOSE = false
)
//
// Helpers
//
func barHelper(options *Options) string { return "bar" }
func echoHelper(str string, nb int) string {
result := ""
for i := 0; i < nb; i++ {
result += str
}
return result
}
func boolHelper(b bool) string {
if b {
return "yes it is"
}
return "absolutely not"
}
func gnakHelper(nb int) string {
result := ""
for i := 0; i < nb; i++ {
result += "GnAK!"
}
return result
}
//
// Tests
//
var helperTests = []Test{
{
"simple helper",
`{{foo}}`,
nil, nil,
map[string]interface{}{"foo": barHelper},
nil,
`bar`,
},
{
"helper with literal string param",
`{{echo "foo" 1}}`,
nil, nil,
map[string]interface{}{"echo": echoHelper},
nil,
`foo`,
},
{
"helper with identifier param",
`{{echo foo 1}}`,
map[string]interface{}{"foo": "bar"},
nil,
map[string]interface{}{"echo": echoHelper},
nil,
`bar`,
},
{
"helper with literal boolean param",
`{{bool true}}`,
nil, nil,
map[string]interface{}{"bool": boolHelper},
nil,
`yes it is`,
},
{
"helper with literal boolean param",
`{{bool false}}`,
nil, nil,
map[string]interface{}{"bool": boolHelper},
nil,
`absolutely not`,
},
{
"helper with literal boolean param",
`{{gnak 5}}`,
nil, nil,
map[string]interface{}{"gnak": gnakHelper},
nil,
`GnAK!GnAK!GnAK!GnAK!GnAK!`,
},
{
"helper with several parameters",
`{{echo "GnAK!" 3}}`,
nil, nil,
map[string]interface{}{"echo": echoHelper},
nil,
`GnAK!GnAK!GnAK!`,
},
{
"#if helper with true literal",
`{{#if true}}YES MAN{{/if}}`,
nil, nil, nil, nil,
`YES MAN`,
},
{
"#if helper with false literal",
`{{#if false}}YES MAN{{/if}}`,
nil, nil, nil, nil,
``,
},
{
"#if helper with truthy identifier",
`{{#if ok}}YES MAN{{/if}}`,
map[string]interface{}{"ok": true},
nil, nil, nil,
`YES MAN`,
},
{
"#if helper with falsy identifier",
`{{#if ok}}YES MAN{{/if}}`,
map[string]interface{}{"ok": false},
nil, nil, nil,
``,
},
{
"#unless helper with true literal",
`{{#unless true}}YES MAN{{/unless}}`,
nil, nil, nil, nil,
``,
},
{
"#unless helper with false literal",
`{{#unless false}}YES MAN{{/unless}}`,
nil, nil, nil, nil,
`YES MAN`,
},
{
"#unless helper with truthy identifier",
`{{#unless ok}}YES MAN{{/unless}}`,
map[string]interface{}{"ok": true},
nil, nil, nil,
``,
},
{
"#unless helper with falsy identifier",
`{{#unless ok}}YES MAN{{/unless}}`,
map[string]interface{}{"ok": false},
nil, nil, nil,
`YES MAN`,
},
{
"#equal helper with same string var",
`{{#equal foo "bar"}}YES MAN{{/equal}}`,
map[string]interface{}{"foo": "bar"},
nil, nil, nil,
`YES MAN`,
},
{
"#equal helper with different string var",
`{{#equal foo "baz"}}YES MAN{{/equal}}`,
map[string]interface{}{"foo": "bar"},
nil, nil, nil,
``,
},
{
"#equal helper with same string vars",
`{{#equal foo bar}}YES MAN{{/equal}}`,
map[string]interface{}{"foo": "baz", "bar": "baz"},
nil, nil, nil,
`YES MAN`,
},
{
"#equal helper with different string vars",
`{{#equal foo bar}}YES MAN{{/equal}}`,
map[string]interface{}{"foo": "baz", "bar": "tag"},
nil, nil, nil,
``,
},
{
"#equal helper with same integer var",
`{{#equal foo 1}}YES MAN{{/equal}}`,
map[string]interface{}{"foo": 1},
nil, nil, nil,
`YES MAN`,
},
{
"#equal helper with different integer var",
`{{#equal foo 0}}YES MAN{{/equal}}`,
map[string]interface{}{"foo": 1},
nil, nil, nil,
``,
},
{
"#equal helper inside HTML tag",
`<option value="test" {{#equal value "test"}}selected{{/equal}}>Test</option>`,
map[string]interface{}{"value": "test"},
nil, nil, nil,
`<option value="test" selected>Test</option>`,
},
{
"#equal full example",
`{{#equal foo "bar"}}foo is bar{{/equal}}
{{#equal foo baz}}foo is the same as baz{{/equal}}
{{#equal nb 0}}nothing{{/equal}}
{{#equal nb 1}}there is one{{/equal}}
{{#equal nb "1"}}everything is stringified before comparison{{/equal}}`,
map[string]interface{}{
"foo": "bar",
"baz": "bar",
"nb": 1,
},
nil, nil, nil,
`foo is bar
foo is the same as baz
there is one
everything is stringified before comparison`,
},
}
//
// Let's go
//
func TestHelper(t *testing.T) {
t.Parallel()
launchTests(t, helperTests)
}
func TestRemoveHelper(t *testing.T) {
RegisterHelper("testremovehelper", func() string { return "" })
if _, ok := helpers["testremovehelper"]; !ok {
t.Error("Failed to register global helper")
}
RemoveHelper("testremovehelper")
if _, ok := helpers["testremovehelper"]; ok {
t.Error("Failed to remove global helper")
}
}
//
// Fixes: https://github.com/aymerick/raymond/issues/2
//
type Author struct {
FirstName string
LastName string
}
func TestHelperCtx(t *testing.T) {
RegisterHelper("template", func(name string, options *Options) SafeString {
context := options.Ctx()
template := name + " - {{ firstName }} {{ lastName }}"
result, _ := Render(template, context)
return SafeString(result)
})
template := `By {{ template "namefile" }}`
context := Author{"Alan", "Johnson"}
result, _ := Render(template, context)
if result != "By namefile - Alan Johnson" {
t.Errorf("Failed to render template in helper: %q", result)
}
}

View File

@ -0,0 +1,100 @@
package handlebarsjs
import (
"fmt"
"os"
"path"
"strconv"
"testing"
"git.reinaldyrafli.com/aldy505/handlebars-go"
)
// cf. https://github.com/aymerick/go-fuzz-tests/handlebars
const dumpTpl = false
var dumpTplNb = 0
type Test struct {
name string
input string
data interface{}
privData map[string]interface{}
helpers map[string]interface{}
partials map[string]string
output interface{}
}
func launchTests(t *testing.T, tests []Test) {
t.Parallel()
for _, test := range tests {
var err error
var tpl *handlebars.Template
if dumpTpl {
filename := strconv.Itoa(dumpTplNb)
if err := os.WriteFile(path.Join(".", "dump_tpl", filename), []byte(test.input), 0644); err != nil {
panic(err)
}
dumpTplNb++
}
// parse template
tpl, err = handlebars.Parse(test.input)
if err != nil {
t.Errorf("Test '%s' failed - Failed to parse template\ninput:\n\t'%s'\nerror:\n\t%s", test.name, test.input, err)
} else {
if len(test.helpers) > 0 {
// register helpers
tpl.RegisterHelpers(test.helpers)
}
if len(test.partials) > 0 {
// register partials
tpl.RegisterPartials(test.partials)
}
// setup private data frame
var privData *handlebars.DataFrame
if test.privData != nil {
privData = handlebars.NewDataFrame()
for k, v := range test.privData {
privData.Set(k, v)
}
}
// render template
output, err := tpl.ExecWith(test.data, privData)
if err != nil {
t.Errorf("Test '%s' failed\ninput:\n\t'%s'\ndata:\n\t%s\nerror:\n\t%s\nAST:\n\t%s", test.name, test.input, handlebars.Str(test.data), err, tpl.PrintAST())
} else {
// check output
var expectedArr []string
expectedArr, ok := test.output.([]string)
if ok {
match := false
for _, expectedStr := range expectedArr {
if expectedStr == output {
match = true
break
}
}
if !match {
t.Errorf("Test '%s' failed\ninput:\n\t'%s'\ndata:\n\t%s\npartials:\n\t%s\nexpected\n\t%q\ngot\n\t%q\nAST:\n%s", test.name, test.input, handlebars.Str(test.data), handlebars.Str(test.partials), expectedArr, output, tpl.PrintAST())
}
} else {
expectedStr, ok := test.output.(string)
if !ok {
panic(fmt.Errorf("Erroneous test output description: %q", test.output))
}
if expectedStr != output {
t.Errorf("Test '%s' failed\ninput:\n\t'%s'\ndata:\n\t%s\npartials:\n\t%s\nexpected\n\t%q\ngot\n\t%q\nAST:\n%s", test.name, test.input, handlebars.Str(test.data), handlebars.Str(test.partials), expectedStr, output, tpl.PrintAST())
}
}
}
}
}
}

View File

@ -0,0 +1,649 @@
package handlebarsjs
import (
"fmt"
"regexp"
"testing"
"git.reinaldyrafli.com/aldy505/handlebars-go"
)
// Those tests come from:
//
// https://github.com/wycats/handlebars.js/blob/master/spec/basic.js
var basicTests = []Test{
{
"most basic",
"{{foo}}",
map[string]string{"foo": "foo"},
nil, nil, nil,
"foo",
},
{
"escaping (1)",
"\\{{foo}}",
map[string]string{"foo": "food"},
nil, nil, nil,
"{{foo}}",
},
{
"escaping (2)",
"content \\{{foo}}",
map[string]string{},
nil, nil, nil,
"content {{foo}}",
},
{
"escaping (3)",
"\\\\{{foo}}",
map[string]string{"foo": "food"},
nil, nil, nil,
"\\food",
},
{
"escaping (4)",
"content \\\\{{foo}}",
map[string]string{"foo": "food"},
nil, nil, nil,
"content \\food",
},
{
"escaping (5)",
"\\\\ {{foo}}",
map[string]string{"foo": "food"},
nil, nil, nil,
"\\\\ food",
},
{
"compiling with a basic context",
"Goodbye\n{{cruel}}\n{{world}}!",
map[string]string{"cruel": "cruel", "world": "world"},
nil, nil, nil,
"Goodbye\ncruel\nworld!",
},
{
"compiling with an undefined context (1)",
"Goodbye\n{{cruel}}\n{{world.bar}}!",
nil, nil, nil, nil,
"Goodbye\n\n!",
},
{
"compiling with an undefined context (2)",
"{{#unless foo}}Goodbye{{../test}}{{test2}}{{/unless}}",
nil, nil, nil, nil,
"Goodbye",
},
{
"comments (1)",
"{{! Goodbye}}Goodbye\n{{cruel}}\n{{world}}!",
map[string]string{"cruel": "cruel", "world": "world"},
nil, nil, nil,
"Goodbye\ncruel\nworld!",
},
{
"comments (2)",
" {{~! comment ~}} blah",
nil, nil, nil, nil,
"blah",
},
{
"comments (3)",
" {{~!-- long-comment --~}} blah",
nil, nil, nil, nil,
"blah",
},
{
"comments (4)",
" {{! comment ~}} blah",
nil, nil, nil, nil,
" blah",
},
{
"comments (5)",
" {{!-- long-comment --~}} blah",
nil, nil, nil, nil,
" blah",
},
{
"comments (6)",
" {{~! comment}} blah",
nil, nil, nil, nil,
" blah",
},
{
"comments (7)",
" {{~!-- long-comment --}} blah",
nil, nil, nil, nil,
" blah",
},
{
"boolean (1)",
"{{#goodbye}}GOODBYE {{/goodbye}}cruel {{world}}!",
map[string]interface{}{"goodbye": true, "world": "world"},
nil, nil, nil,
"GOODBYE cruel world!",
},
{
"boolean (2)",
"{{#goodbye}}GOODBYE {{/goodbye}}cruel {{world}}!",
map[string]interface{}{"goodbye": false, "world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"zeros (1)",
"num1: {{num1}}, num2: {{num2}}",
map[string]interface{}{"num1": 42, "num2": 0},
nil, nil, nil,
"num1: 42, num2: 0",
},
{
"zeros (2)",
"num: {{.}}",
0,
nil, nil, nil,
"num: 0",
},
{
"zeros (3)",
"num: {{num1/num2}}",
map[string]map[string]interface{}{"num1": {"num2": 0}},
nil, nil, nil,
"num: 0",
},
{
"false (1)",
"val1: {{val1}}, val2: {{val2}}",
map[string]interface{}{"val1": false, "val2": false},
nil, nil, nil,
"val1: false, val2: false",
},
{
"false (2)",
"val: {{.}}",
false,
nil, nil, nil,
"val: false",
},
{
"false (3)",
"val: {{val1/val2}}",
map[string]map[string]interface{}{"val1": {"val2": false}},
nil, nil, nil,
"val: false",
},
{
"false (4)",
"val1: {{{val1}}}, val2: {{{val2}}}",
map[string]interface{}{"val1": false, "val2": false},
nil, nil, nil,
"val1: false, val2: false",
},
{
"false (5)",
"val: {{{val1/val2}}}",
map[string]map[string]interface{}{"val1": {"val2": false}},
nil, nil, nil,
"val: false",
},
{
"newlines (1)",
"Alan's\nTest",
nil, nil, nil, nil,
"Alan's\nTest",
},
{
"newlines (2)",
"Alan's\rTest",
nil, nil, nil, nil,
"Alan's\rTest",
},
{
"escaping text (1)",
"Awesome's",
map[string]string{},
nil, nil, nil,
"Awesome's",
},
{
"escaping text (2)",
"Awesome\\",
map[string]string{},
nil, nil, nil,
"Awesome\\",
},
{
"escaping text (3)",
"Awesome\\\\ foo",
map[string]string{},
nil, nil, nil,
"Awesome\\\\ foo",
},
{
"escaping text (4)",
"Awesome {{foo}}",
map[string]string{"foo": "\\"},
nil, nil, nil,
"Awesome \\",
},
{
"escaping text (5)",
" ' ' ",
map[string]string{},
nil, nil, nil,
" ' ' ",
},
{
"escaping expressions (6)",
"{{{awesome}}}",
map[string]string{"awesome": "&'\\<>"},
nil, nil, nil,
"&'\\<>",
},
{
"escaping expressions (7)",
"{{&awesome}}",
map[string]string{"awesome": "&'\\<>"},
nil, nil, nil,
"&'\\<>",
},
{
"escaping expressions (8)",
"{{awesome}}",
map[string]string{"awesome": "&\"'`\\<>"},
nil, nil, nil,
"&amp;&quot;&apos;`\\&lt;&gt;",
},
{
"escaping expressions (9)",
"{{awesome}}",
map[string]string{"awesome": "Escaped, <b> looks like: &lt;b&gt;"},
nil, nil, nil,
"Escaped, &lt;b&gt; looks like: &amp;lt;b&amp;gt;",
},
{
"functions returning safestrings shouldn't be escaped",
"{{awesome}}",
map[string]interface{}{"awesome": func() handlebars.SafeString { return handlebars.SafeString("&'\\<>") }},
nil, nil, nil,
"&'\\<>",
},
{
"functions (1)",
"{{awesome}}",
map[string]interface{}{"awesome": func() string { return "Awesome" }},
nil, nil, nil,
"Awesome",
},
{
"functions (2)",
"{{awesome}}",
map[string]interface{}{"awesome": func(options *handlebars.Options) string {
return options.ValueStr("more")
}, "more": "More awesome"},
nil, nil, nil,
"More awesome",
},
{
"functions with context argument",
"{{awesome frank}}",
map[string]interface{}{"awesome": func(context string) string {
return context
}, "frank": "Frank"},
nil, nil, nil,
"Frank",
},
{
"pathed functions with context argument",
"{{bar.awesome frank}}",
map[string]interface{}{"bar": map[string]interface{}{"awesome": func(context string) string {
return context
}}, "frank": "Frank"},
nil, nil, nil,
"Frank",
},
{
"depthed functions with context argument",
"{{#with frank}}{{../awesome .}}{{/with}}",
map[string]interface{}{"awesome": func(context string) string {
return context
}, "frank": "Frank"},
nil, nil, nil,
"Frank",
},
{
"block functions with context argument",
"{{#awesome 1}}inner {{.}}{{/awesome}}",
map[string]interface{}{"awesome": func(context interface{}, options *handlebars.Options) string {
return options.FnWith(context)
}},
nil, nil, nil,
"inner 1",
},
{
"depthed block functions with context argument",
"{{#with value}}{{#../awesome 1}}inner {{.}}{{/../awesome}}{{/with}}",
map[string]interface{}{
"awesome": func(context interface{}, options *handlebars.Options) string {
return options.FnWith(context)
},
"value": true,
},
nil, nil, nil,
"inner 1",
},
{
"block functions without context argument",
"{{#awesome}}inner{{/awesome}}",
map[string]interface{}{
"awesome": func(options *handlebars.Options) string {
return options.Fn()
},
},
nil, nil, nil,
"inner",
},
// // @note I don't even understand why this test passes with the JS implementation... it should be
// // the responsability of the function to evaluate the block
// {
// "pathed block functions without context argument",
// "{{#foo.awesome}}inner{{/foo.awesome}}",
// map[string]map[string]interface{}{
// "foo": {
// "awesome": func(options *handlebars.Options) interface{} {
// return options.Ctx()
// },
// },
// },
// nil, nil, nil,
// "inner",
// },
// // @note I don't even understand why this test passes with the JS implementation... it should be
// // the responsability of the function to evaluate the block
// {
// "depthed block functions without context argument",
// "{{#with value}}{{#../awesome}}inner{{/../awesome}}{{/with}}",
// map[string]interface{}{
// "value": true,
// "awesome": func(options *handlebars.Options) interface{} {
// return options.Ctx()
// },
// },
// nil, nil, nil,
// "inner",
// },
{
"paths with hyphens (1)",
"{{foo-bar}}",
map[string]string{"foo-bar": "baz"},
nil, nil, nil,
"baz",
},
{
"paths with hyphens (2)",
"{{foo.foo-bar}}",
map[string]map[string]string{"foo": {"foo-bar": "baz"}},
nil, nil, nil,
"baz",
},
{
"paths with hyphens (3)",
"{{foo/foo-bar}}",
map[string]map[string]string{"foo": {"foo-bar": "baz"}},
nil, nil, nil,
"baz",
},
{
"nested paths",
"Goodbye {{alan/expression}} world!",
map[string]map[string]string{"alan": {"expression": "beautiful"}},
nil, nil, nil,
"Goodbye beautiful world!",
},
{
"nested paths with empty string value",
"Goodbye {{alan/expression}} world!",
map[string]map[string]string{"alan": {"expression": ""}},
nil, nil, nil,
"Goodbye world!",
},
{
"literal paths (1)",
"Goodbye {{[@alan]/expression}} world!",
map[string]map[string]string{"@alan": {"expression": "beautiful"}},
nil, nil, nil,
"Goodbye beautiful world!",
},
{
"literal paths (2)",
"Goodbye {{[foo bar]/expression}} world!",
map[string]map[string]string{"foo bar": {"expression": "beautiful"}},
nil, nil, nil,
"Goodbye beautiful world!",
},
{
"literal references",
"Goodbye {{[foo bar]}} world!",
map[string]string{"foo bar": "beautiful"},
nil, nil, nil,
"Goodbye beautiful world!",
},
// @note MMm ok, well... no... I don't see the purpose of that test
{
"that current context path ({{.}}) doesn't hit helpers",
"test: {{.}}",
nil, nil,
map[string]interface{}{"helper": func() string {
panic("fail")
}},
nil,
"test: ",
},
{
"complex but empty paths (1)",
"{{person/name}}",
map[string]map[string]interface{}{"person": {"name": nil}},
nil, nil, nil,
"",
},
{
"complex but empty paths (2)",
"{{person/name}}",
map[string]map[string]string{"person": {}},
nil, nil, nil,
"",
},
{
"this keyword in paths (1)",
"{{#goodbyes}}{{this}}{{/goodbyes}}",
map[string]interface{}{"goodbyes": []string{"goodbye", "Goodbye", "GOODBYE"}},
nil, nil, nil,
"goodbyeGoodbyeGOODBYE",
},
{
"this keyword in paths (2)",
"{{#hellos}}{{this/text}}{{/hellos}}",
map[string]interface{}{"hellos": []interface{}{
map[string]string{"text": "hello"},
map[string]string{"text": "Hello"},
map[string]string{"text": "HELLO"},
}},
nil, nil, nil,
"helloHelloHELLO",
},
{
"this keyword nested inside path' (1)",
"{{[this]}}",
map[string]string{"this": "bar"},
nil, nil, nil,
"bar",
},
{
"this keyword nested inside path' (2)",
"{{text/[this]}}",
map[string]map[string]string{"text": {"this": "bar"}},
nil, nil, nil,
"bar",
},
{
"this keyword in helpers (1)",
"{{#goodbyes}}{{foo this}}{{/goodbyes}}",
map[string]interface{}{"goodbyes": []string{"goodbye", "Goodbye", "GOODBYE"}},
nil,
map[string]interface{}{"foo": barSuffixHelper},
nil,
"bar goodbyebar Goodbyebar GOODBYE",
},
{
"this keyword in helpers (2)",
"{{#hellos}}{{foo this/text}}{{/hellos}}",
map[string]interface{}{"hellos": []map[string]string{{"text": "hello"}, {"text": "Hello"}, {"text": "HELLO"}}},
nil,
map[string]interface{}{"foo": barSuffixHelper},
nil,
"bar hellobar Hellobar HELLO",
},
{
"this keyword nested inside helpers param (1)",
"{{foo [this]}}",
map[string]interface{}{"this": "bar"},
nil,
map[string]interface{}{"foo": echoHelper},
nil,
"bar",
},
{
"this keyword nested inside helpers param (2)",
"{{foo text/[this]}}",
map[string]map[string]string{"text": {"this": "bar"}},
nil,
map[string]interface{}{"foo": echoHelper},
nil,
"bar",
},
{
"pass string literals (1)",
`{{"foo"}}`,
map[string]string{},
nil, nil, nil,
"",
},
{
"pass string literals (2)",
`{{"foo"}}`,
map[string]string{"foo": "bar"},
nil, nil, nil,
"bar",
},
{
"pass string literals (3)",
`{{#"foo"}}{{.}}{{/"foo"}}`,
map[string]interface{}{"foo": []string{"bar", "baz"}},
nil, nil, nil,
"barbaz",
},
{
"pass number literals (1)",
"{{12}}",
map[string]string{},
nil, nil, nil,
"",
},
{
"pass number literals (2)",
"{{12}}",
map[string]string{"12": "bar"},
nil, nil, nil,
"bar",
},
{
"pass number literals (3)",
"{{12.34}}",
map[string]string{},
nil, nil, nil,
"",
},
{
"pass number literals (4)",
"{{12.34}}",
map[string]string{"12.34": "bar"},
nil, nil, nil,
"bar",
},
{
"pass number literals (5)",
"{{12.34 1}}",
map[string]interface{}{"12.34": func(context string) string {
return "bar" + context
}},
nil, nil, nil,
"bar1",
},
{
"pass boolean literals (1)",
"{{true}}",
map[string]string{},
nil, nil, nil,
"",
},
{
"pass boolean literals (2)",
"{{true}}",
map[string]string{"": "foo"},
nil, nil, nil,
"",
},
{
"pass boolean literals (3)",
"{{false}}",
map[string]string{"false": "foo"},
nil, nil, nil,
"foo",
},
{
"should handle literals in subexpression",
"{{foo (false)}}",
map[string]interface{}{"false": func() string { return "bar" }},
nil,
map[string]interface{}{"foo": func(context string) string {
return context
}},
nil,
"bar",
},
}
func TestBasic(t *testing.T) {
launchTests(t, basicTests)
}
func TestBasicErrors(t *testing.T) {
t.Parallel()
var err error
inputs := []string{
// this keyword nested inside path
"{{#hellos}}{{text/this/foo}}{{/hellos}}",
// this keyword nested inside helpers param
"{{#hellos}}{{foo text/this/foo}}{{/hellos}}",
}
expectedError := regexp.QuoteMeta("Invalid path: text/this")
for _, input := range inputs {
_, err = handlebars.Parse(input)
if err == nil {
t.Errorf("Test failed - Error expected")
}
match, errMatch := regexp.MatchString(expectedError, fmt.Sprint(err))
if errMatch != nil {
panic("Failed to match regexp")
}
if !match {
t.Errorf("Test failed - Expected error:\n\t%s\n\nGot:\n\t%s", expectedError, err)
}
}
}

View File

@ -0,0 +1,207 @@
package handlebarsjs
import "testing"
// Those tests come from:
//
// https://github.com/wycats/handlebars.js/blob/master/spec/blocks.js
var blocksTests = []Test{
{
"array (1) - Arrays iterate over the contents when not empty",
"{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"goodbye! Goodbye! GOODBYE! cruel world!",
},
{
"array (2) - Arrays ignore the contents when empty",
"{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{}, "world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"array without data",
"{{#goodbyes}}{{text}}{{/goodbyes}} {{#goodbyes}}{{text}}{{/goodbyes}}",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"goodbyeGoodbyeGOODBYE goodbyeGoodbyeGOODBYE",
},
{
"array with @index - The @index variable is used",
"{{#goodbyes}}{{@index}}. {{text}}! {{/goodbyes}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!",
},
{
"empty block (1) - Arrays iterate over the contents when not empty",
"{{#goodbyes}}{{/goodbyes}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"empty block (1) - Arrays ignore the contents when empty",
"{{#goodbyes}}{{/goodbyes}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{}, "world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"block with complex lookup - Templates can access variables in contexts up the stack with relative path syntax",
"{{#goodbyes}}{{text}} cruel {{../name}}! {{/goodbyes}}",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "name": "Alan"},
nil, nil, nil,
"goodbye cruel Alan! Goodbye cruel Alan! GOODBYE cruel Alan! ",
},
{
"multiple blocks with complex lookup",
"{{#goodbyes}}{{../name}}{{../name}}{{/goodbyes}}",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "name": "Alan"},
nil, nil, nil,
"AlanAlanAlanAlanAlanAlan",
},
// @todo "{{#goodbyes}}{{text}} cruel {{foo/../name}}! {{/goodbyes}}" should throw error
{
"block with deep nested complex lookup",
"{{#outer}}Goodbye {{#inner}}cruel {{../sibling}} {{../../omg}}{{/inner}}{{/outer}}",
map[string]interface{}{"omg": "OMG!", "outer": []map[string]interface{}{{"sibling": "sad", "inner": []map[string]string{{"text": "goodbye"}}}}},
nil, nil, nil,
"Goodbye cruel sad OMG!",
},
{
"inverted sections with unset value - Inverted section rendered when value isn't set.",
"{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}",
map[string]interface{}{},
nil, nil, nil,
"Right On!",
},
{
"inverted sections with false value - Inverted section rendered when value is false.",
"{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}",
map[string]interface{}{"goodbyes": false},
nil, nil, nil,
"Right On!",
},
{
"inverted section with empty set - Inverted section rendered when value is empty set.",
"{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}",
map[string]interface{}{"goodbyes": []interface{}{}},
nil, nil, nil,
"Right On!",
},
{
"block inverted sections",
"{{#people}}{{name}}{{^}}{{none}}{{/people}}",
map[string]interface{}{"none": "No people"},
nil, nil, nil,
"No people",
},
{
"chained inverted sections (1)",
"{{#people}}{{name}}{{else if none}}{{none}}{{/people}}",
map[string]interface{}{"none": "No people"},
nil, nil, nil,
"No people",
},
{
"chained inverted sections (2)",
"{{#people}}{{name}}{{else if nothere}}fail{{else unless nothere}}{{none}}{{/people}}",
map[string]interface{}{"none": "No people"},
nil, nil, nil,
"No people",
},
{
"chained inverted sections (3)",
"{{#people}}{{name}}{{else if none}}{{none}}{{else}}fail{{/people}}",
map[string]interface{}{"none": "No people"},
nil, nil, nil,
"No people",
},
// @todo "{{#people}}{{name}}{{else if none}}{{none}}{{/if}}" should throw error
{
"block inverted sections with empty arrays",
"{{#people}}{{name}}{{^}}{{none}}{{/people}}",
map[string]interface{}{"none": "No people", "people": map[string]interface{}{}},
nil, nil, nil,
"No people",
},
{
"block standalone else sections (1)",
"{{#people}}\n{{name}}\n{{^}}\n{{none}}\n{{/people}}\n",
map[string]interface{}{"none": "No people"},
nil, nil, nil,
"No people\n",
},
{
"block standalone else sections (2)",
"{{#none}}\n{{.}}\n{{^}}\n{{none}}\n{{/none}}\n",
map[string]interface{}{"none": "No people"},
nil, nil, nil,
"No people\n",
},
{
"block standalone else sections (3)",
"{{#people}}\n{{name}}\n{{^}}\n{{none}}\n{{/people}}\n",
map[string]interface{}{"none": "No people"},
nil, nil, nil,
"No people\n",
},
{
"block standalone chained else sections (1)",
"{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{/people}}\n",
map[string]interface{}{"none": "No people"},
nil, nil, nil,
"No people\n",
},
{
"block standalone chained else sections (2)",
"{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{^}}\n{{/people}}\n",
map[string]interface{}{"none": "No people"},
nil, nil, nil,
"No people\n",
},
{
"should handle nesting",
"{{#data}}\n{{#if true}}\n{{.}}\n{{/if}}\n{{/data}}\nOK.",
map[string]interface{}{"data": []int{1, 3, 5}},
nil, nil, nil,
"1\n3\n5\nOK.",
},
// // @todo compat mode
// {
// "block with deep recursive lookup lookup",
// "{{#outer}}Goodbye {{#inner}}cruel {{omg}}{{/inner}}{{/outer}}",
// map[string]interface{}{"omg": "OMG!", "outer": []map[string]interface{}{{"inner": []map[string]string{{"text": "goodbye"}}}}},
// nil,
// nil,
// nil,
// "Goodbye cruel OMG!",
// },
// // @todo compat mode
// {
// "block with deep recursive pathed lookup",
// "{{#outer}}Goodbye {{#inner}}cruel {{omg.yes}}{{/inner}}{{/outer}}",
// map[string]interface{}{"omg": map[string]string{"yes": "OMG!"}, "outer": []map[string]interface{}{{"inner": []map[string]string{{"yes": "no", "text": "goodbye"}}}}},
// nil,
// nil,
// nil,
// "Goodbye cruel OMG!",
// },
{
"block with missed recursive lookup",
"{{#outer}}Goodbye {{#inner}}cruel {{omg.yes}}{{/inner}}{{/outer}}",
map[string]interface{}{"omg": map[string]string{"no": "OMG!"}, "outer": []map[string]interface{}{{"inner": []map[string]string{{"yes": "no", "text": "goodbye"}}}}},
nil, nil, nil,
"Goodbye cruel ",
},
}
func TestBlocks(t *testing.T) {
launchTests(t, blocksTests)
}

View File

@ -0,0 +1,340 @@
package handlebarsjs
import "testing"
// Those tests come from:
//
// https://github.com/wycats/handlebars.js/blob/master/spec/builtin.js
var builtinsTests = []Test{
{
"#if - if with boolean argument shows the contents when true",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{"goodbye": true, "world": "world"},
nil, nil, nil,
"GOODBYE cruel world!",
},
{
"#if - if with string argument shows the contents",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{"goodbye": "dummy", "world": "world"},
nil, nil, nil,
"GOODBYE cruel world!",
},
{
"#if - if with boolean argument does not show the contents when false",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{"goodbye": false, "world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"#if - if with undefined does not show the contents",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{"world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"#if - if with non-empty array shows the contents",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{"goodbye": []string{"foo"}, "world": "world"},
nil, nil, nil,
"GOODBYE cruel world!",
},
{
"#if - if with empty array does not show the contents",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{"goodbye": []string{}, "world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"#if - if with zero does not show the contents",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{"goodbye": 0, "world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"#if - if with zero and includeZero option shows the contents",
"{{#if goodbye includeZero=true}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{"goodbye": 0, "world": "world"},
nil, nil, nil,
"GOODBYE cruel world!",
},
{
"#if - if with function shows the contents when function returns true",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{
"goodbye": func() bool { return true },
"world": "world",
},
nil, nil, nil,
"GOODBYE cruel world!",
},
{
"#if - if with function shows the contents when function returns string",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{
"goodbye": func() string { return "world" },
"world": "world",
},
nil, nil, nil,
"GOODBYE cruel world!",
},
{
"#if - if with function does not show the contents when returns false",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{
"goodbye": func() bool { return false },
"world": "world",
},
nil, nil, nil,
"cruel world!",
},
{
"#if - if with function does not show the contents when returns undefined",
"{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!",
map[string]interface{}{
"goodbye": func() interface{} { return nil },
"world": "world",
},
nil, nil, nil,
"cruel world!",
},
{
"#with",
"{{#with person}}{{first}} {{last}}{{/with}}",
map[string]interface{}{"person": map[string]string{"first": "Alan", "last": "Johnson"}},
nil, nil, nil,
"Alan Johnson",
},
{
"#with - with with function argument",
"{{#with person}}{{first}} {{last}}{{/with}}",
map[string]interface{}{
"person": func() map[string]string { return map[string]string{"first": "Alan", "last": "Johnson"} },
}, nil, nil, nil,
"Alan Johnson",
},
{
"#with - with with else",
"{{#with person}}Person is present{{else}}Person is not present{{/with}}",
map[string]interface{}{},
nil, nil, nil,
"Person is not present",
},
{
"#each - each with array argument iterates over the contents when not empty",
"{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"goodbye! Goodbye! GOODBYE! cruel world!",
},
{
"#each - each with array argument ignores the contents when empty",
"{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{}, "world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"#each - each without data (1)",
"{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"goodbye! Goodbye! GOODBYE! cruel world!",
},
{
"#each - each without data (2)",
"{{#each .}}{{.}}{{/each}}",
map[string]interface{}{"goodbyes": "cruel", "world": "world"},
nil, nil, nil,
// note: a go hash is not ordered, so result may vary, this behaviour differs from the JS implementation
[]string{"cruelworld", "worldcruel"},
},
{
"#each - each without context",
"{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!",
nil, nil, nil, nil,
"cruel !",
},
// NOTE: we test with a map instead of an object
{
"#each - each with an object and @key (map)",
"{{#each goodbyes}}{{@key}}. {{text}}! {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": map[interface{}]map[string]string{"<b>#1</b>": {"text": "goodbye"}, 2: {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
[]string{"&lt;b&gt;#1&lt;/b&gt;. goodbye! 2. GOODBYE! cruel world!", "2. GOODBYE! &lt;b&gt;#1&lt;/b&gt;. goodbye! cruel world!"},
},
// NOTE: An additional test with a struct, but without an html stuff for the key, because it is impossible
{
"#each - each with an object and @key (struct)",
"{{#each goodbyes}}{{@key}}. {{text}}! {{/each}}cruel {{world}}!",
map[string]interface{}{
"goodbyes": struct {
Foo map[string]string
Bar map[string]int
}{map[string]string{"text": "baz"}, map[string]int{"text": 10}},
"world": "world",
},
nil, nil, nil,
[]string{"Foo. baz! Bar. 10! cruel world!", "Bar. 10! Foo. baz! cruel world!"},
},
{
"#each - each with @index",
"{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!",
},
{
"#each - each with nested @index",
"{{#each goodbyes}}{{@index}}. {{text}}! {{#each ../goodbyes}}{{@index}} {{/each}}After {{@index}} {{/each}}{{@index}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"0. goodbye! 0 1 2 After 0 1. Goodbye! 0 1 2 After 1 2. GOODBYE! 0 1 2 After 2 cruel world!",
},
{
"#each - each with block params",
"{{#each goodbyes as |value index|}}{{index}}. {{value.text}}! {{#each ../goodbyes as |childValue childIndex|}} {{index}} {{childIndex}}{{/each}} After {{index}} {{/each}}{{index}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}}, "world": "world"},
nil, nil, nil,
"0. goodbye! 0 0 0 1 After 0 1. Goodbye! 1 0 1 1 After 1 cruel world!",
},
// @note: That test differs from JS impl because maps and structs are not ordered in go
{
"#each - each object with @index",
"{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": map[string]map[string]string{"a": {"text": "goodbye"}, "b": {"text": "Goodbye"}}, "world": "world"},
nil, nil, nil,
[]string{"0. goodbye! 1. Goodbye! cruel world!", "0. Goodbye! 1. goodbye! cruel world!"},
},
{
"#each - each with nested @first",
"{{#each goodbyes}}({{#if @first}}{{text}}! {{/if}}{{#each ../goodbyes}}{{#if @first}}{{text}}!{{/if}}{{/each}}{{#if @first}} {{text}}!{{/if}}) {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"(goodbye! goodbye! goodbye!) (goodbye!) (goodbye!) cruel world!",
},
// @note: That test differs from JS impl because maps and structs are not ordered in go
{
"#each - each object with @first",
"{{#each goodbyes}}{{#if @first}}{{text}}! {{/if}}{{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": map[string]map[string]string{"foo": {"text": "goodbye"}, "bar": {"text": "Goodbye"}}, "world": "world"},
nil, nil, nil,
[]string{"goodbye! cruel world!", "Goodbye! cruel world!"},
},
{
"#each - each with @last",
"{{#each goodbyes}}{{#if @last}}{{text}}! {{/if}}{{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"GOODBYE! cruel world!",
},
// @note: That test differs from JS impl because maps and structs are not ordered in go
{
"#each - each object with @last",
"{{#each goodbyes}}{{#if @last}}{{text}}! {{/if}}{{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": map[string]map[string]string{"foo": {"text": "goodbye"}, "bar": {"text": "Goodbye"}}, "world": "world"},
nil, nil, nil,
[]string{"goodbye! cruel world!", "Goodbye! cruel world!"},
},
{
"#each - each with nested @last",
"{{#each goodbyes}}({{#if @last}}{{text}}! {{/if}}{{#each ../goodbyes}}{{#if @last}}{{text}}!{{/if}}{{/each}}{{#if @last}} {{text}}!{{/if}}) {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}, "world": "world"},
nil, nil, nil,
"(GOODBYE!) (GOODBYE!) (GOODBYE! GOODBYE! GOODBYE!) cruel world!",
},
{
"#each - each with function argument (1)",
"{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": func() []map[string]string {
return []map[string]string{{"text": "goodbye"}, {"text": "Goodbye"}, {"text": "GOODBYE"}}
}, "world": "world"},
nil, nil, nil,
"goodbye! Goodbye! GOODBYE! cruel world!",
},
{
"#each - each with function argument (2)",
"{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!",
map[string]interface{}{"goodbyes": []map[string]string{}, "world": "world"},
nil, nil, nil,
"cruel world!",
},
{
"#each - data passed to helpers",
"{{#each letters}}{{this}}{{detectDataInsideEach}}{{/each}}",
map[string][]string{"letters": {"a", "b", "c"}},
map[string]interface{}{"exclaim": "!"},
map[string]interface{}{"detectDataInsideEach": detectDataHelper},
nil,
"a!b!c!",
},
// @todo "each on implicit context" should throw error
// SKIP: #log - "should call logger at default level"
// SKIP: #log - "should call logger at data level"
// SKIP: #log - "should output to info"
// SKIP: #log - "should log at data level"
// SKIP: #log - "should handle missing logger"
// @note Test added
// @todo Check log output
{
"#log",
"{{log blah}}",
map[string]string{"blah": "whee"},
nil, nil, nil,
"",
},
// @note Test added
{
"#lookup - should lookup array element",
"{{#each goodbyes}}{{lookup ../data @index}}{{/each}}",
map[string]interface{}{"goodbyes": []int{0, 1}, "data": []string{"foo", "bar"}},
nil, nil, nil,
"foobar",
},
{
"#lookup - should lookup map element",
"{{#each goodbyes}}{{lookup ../data .}}{{/each}}",
map[string]interface{}{"goodbyes": []string{"foo", "bar"}, "data": map[string]string{"foo": "baz", "bar": "bat"}},
nil, nil, nil,
"bazbat",
},
{
"#lookup - should lookup struct field",
"{{#each goodbyes}}{{lookup ../data .}}{{/each}}",
map[string]interface{}{"goodbyes": []string{"Foo", "Bar"}, "data": struct {
Foo string
Bar string
}{"baz", "bat"}},
nil, nil, nil,
"bazbat",
},
{
"#lookup - should lookup arbitrary content",
"{{#each goodbyes}}{{lookup ../data .}}{{/each}}",
map[string]interface{}{"goodbyes": []int{0, 1}, "data": []string{"foo", "bar"}},
nil, nil, nil,
"foobar",
},
{
"#lookup - should not fail on undefined value",
"{{#each goodbyes}}{{lookup ../bar .}}{{/each}}",
map[string]interface{}{"goodbyes": []int{0, 1}, "data": []string{"foo", "bar"}},
nil, nil, nil,
"",
},
}
func TestBuiltins(t *testing.T) {
launchTests(t, builtinsTests)
}

View File

@ -0,0 +1,299 @@
package handlebarsjs
import (
"testing"
"git.reinaldyrafli.com/aldy505/handlebars-go"
)
// Those tests come from:
//
// https://github.com/wycats/handlebars.js/blob/master/spec/data.js
var dataTests = []Test{
{
"passing in data to a compiled function that expects data - works with helpers",
"{{hello}}",
map[string]string{"noun": "cat"},
map[string]interface{}{"adjective": "happy"},
map[string]interface{}{"hello": func(options *handlebars.Options) string {
return options.DataStr("adjective") + " " + options.ValueStr("noun")
}},
nil,
"happy cat",
},
{
"data can be looked up via @foo",
"{{@hello}}",
nil,
map[string]interface{}{"hello": "hello"},
nil, nil,
"hello",
},
{
"deep @foo triggers automatic top-level data",
`{{#let world="world"}}{{#if foo}}{{#if foo}}Hello {{@world}}{{/if}}{{/if}}{{/let}}`,
map[string]bool{"foo": true},
map[string]interface{}{"hello": "hello"},
map[string]interface{}{"let": func(options *handlebars.Options) string {
frame := options.NewDataFrame()
for k, v := range options.Hash() {
frame.Set(k, v)
}
return options.FnData(frame)
}},
nil,
"Hello world",
},
{
"parameter data can be looked up via @foo",
`{{hello @world}}`,
nil,
map[string]interface{}{"world": "world"},
map[string]interface{}{"hello": func(context string) string {
return "Hello " + context
}},
nil,
"Hello world",
},
{
"hash values can be looked up via @foo",
`{{hello noun=@world}}`,
nil,
map[string]interface{}{"world": "world"},
map[string]interface{}{"hello": func(options *handlebars.Options) string {
return "Hello " + options.HashStr("noun")
}},
nil,
"Hello world",
},
{
"nested parameter data can be looked up via @foo.bar",
`{{hello @world.bar}}`,
nil,
map[string]interface{}{"world": map[string]string{"bar": "world"}},
map[string]interface{}{"hello": func(context string) string {
return "Hello " + context
}},
nil,
"Hello world",
},
{
"nested parameter data does not fail with @world.bar",
`{{hello @world.bar}}`,
nil,
map[string]interface{}{"foo": map[string]string{"bar": "world"}},
map[string]interface{}{"hello": func(context string) string {
return "Hello " + context
}},
nil,
// @todo Test differs with JS implementation: we don't output `undefined`
"Hello ",
},
// @todo "parameter data throws when using complex scope references",
{
"data can be functions",
`{{@hello}}`,
nil,
map[string]interface{}{"hello": func() string { return "hello" }},
nil, nil,
"hello",
},
{
"data can be functions with params",
`{{@hello "hello"}}`,
nil,
map[string]interface{}{"hello": func(context string) string { return context }},
nil, nil,
"hello",
},
{
"data is inherited downstream",
`{{#let foo=1 bar=2}}{{#let foo=bar.baz}}{{@bar}}{{@foo}}{{/let}}{{@foo}}{{/let}}`,
map[string]map[string]string{"bar": {"baz": "hello world"}},
nil,
map[string]interface{}{"let": func(options *handlebars.Options) string {
frame := options.NewDataFrame()
for k, v := range options.Hash() {
frame.Set(k, v)
}
return options.FnData(frame)
}},
nil,
"2hello world1",
},
{
"passing in data to a compiled function that expects data - works with helpers in partials",
`{{>myPartial}}`,
map[string]string{"noun": "cat"},
map[string]interface{}{"adjective": "happy"},
map[string]interface{}{"hello": func(options *handlebars.Options) string {
return options.DataStr("adjective") + " " + options.ValueStr("noun")
}},
map[string]string{
"myPartial": "{{hello}}",
},
"happy cat",
},
{
"passing in data to a compiled function that expects data - works with helpers and parameters",
`{{hello world}}`,
map[string]interface{}{"exclaim": true, "world": "world"},
map[string]interface{}{"adjective": "happy"},
map[string]interface{}{"hello": func(context string, options *handlebars.Options) string {
str := "error"
if b, ok := options.Value("exclaim").(bool); ok {
if b {
str = "!"
} else {
str = ""
}
}
return options.DataStr("adjective") + " " + context + str
}},
nil,
"happy world!",
},
{
"passing in data to a compiled function that expects data - works with block helpers",
`{{#hello}}{{world}}{{/hello}}`,
map[string]bool{"exclaim": true},
map[string]interface{}{"adjective": "happy"},
map[string]interface{}{
"hello": func(options *handlebars.Options) string {
return options.Fn()
},
"world": func(options *handlebars.Options) string {
str := "error"
if b, ok := options.Value("exclaim").(bool); ok {
if b {
str = "!"
} else {
str = ""
}
}
return options.DataStr("adjective") + " world" + str
},
},
nil,
"happy world!",
},
{
"passing in data to a compiled function that expects data - works with block helpers that use ..",
`{{#hello}}{{world ../zomg}}{{/hello}}`,
map[string]interface{}{"exclaim": true, "zomg": "world"},
map[string]interface{}{"adjective": "happy"},
map[string]interface{}{
"hello": func(options *handlebars.Options) string {
return options.FnWith(map[string]string{"exclaim": "?"})
},
"world": func(context string, options *handlebars.Options) string {
return options.DataStr("adjective") + " " + context + options.ValueStr("exclaim")
},
},
nil,
"happy world?",
},
{
"passing in data to a compiled function that expects data - data is passed to with block helpers where children use ..",
`{{#hello}}{{world ../zomg}}{{/hello}}`,
map[string]interface{}{"exclaim": true, "zomg": "world"},
map[string]interface{}{"adjective": "happy", "accessData": "#win"},
map[string]interface{}{
"hello": func(options *handlebars.Options) string {
return options.DataStr("accessData") + " " + options.FnWith(map[string]string{"exclaim": "?"})
},
"world": func(context string, options *handlebars.Options) string {
return options.DataStr("adjective") + " " + context + options.ValueStr("exclaim")
},
},
nil,
"#win happy world?",
},
{
"you can override inherited data when invoking a helper",
`{{#hello}}{{world zomg}}{{/hello}}`,
map[string]interface{}{"exclaim": true, "zomg": "planet"},
map[string]interface{}{"adjective": "happy"},
map[string]interface{}{
"hello": func(options *handlebars.Options) string {
ctx := map[string]string{"exclaim": "?", "zomg": "world"}
data := options.NewDataFrame()
data.Set("adjective", "sad")
return options.FnCtxData(ctx, data)
},
"world": func(context string, options *handlebars.Options) string {
return options.DataStr("adjective") + " " + context + options.ValueStr("exclaim")
},
},
nil,
"sad world?",
},
{
"you can override inherited data when invoking a helper with depth",
`{{#hello}}{{world ../zomg}}{{/hello}}`,
map[string]interface{}{"exclaim": true, "zomg": "world"},
map[string]interface{}{"adjective": "happy"},
map[string]interface{}{
"hello": func(options *handlebars.Options) string {
ctx := map[string]string{"exclaim": "?"}
data := options.NewDataFrame()
data.Set("adjective", "sad")
return options.FnCtxData(ctx, data)
},
"world": func(context string, options *handlebars.Options) string {
return options.DataStr("adjective") + " " + context + options.ValueStr("exclaim")
},
},
nil,
"sad world?",
},
{
"@root - the root context can be looked up via @root",
`{{@root.foo}}`,
map[string]interface{}{"foo": "hello"},
nil, nil, nil,
"hello",
},
{
"@root - passed root values take priority",
`{{@root.foo}}`,
nil,
map[string]interface{}{"root": map[string]string{"foo": "hello"}},
nil, nil,
"hello",
},
{
"nesting - the root context can be looked up via @root",
`{{#helper}}{{#helper}}{{@./depth}} {{@../depth}} {{@../../depth}}{{/helper}}{{/helper}}`,
map[string]interface{}{"foo": "hello"},
map[string]interface{}{"depth": 0},
map[string]interface{}{
"helper": func(options *handlebars.Options) string {
data := options.NewDataFrame()
if depth, ok := options.Data("depth").(int); ok {
data.Set("depth", depth+1)
}
return options.FnData(data)
},
},
nil,
"2 1 0",
},
}
func TestData(t *testing.T) {
launchTests(t, dataTests)
}

View File

@ -0,0 +1,2 @@
// Package handlebarjs contains all the tests that come from handlebars.js project.
package handlebarsjs

View File

@ -0,0 +1,666 @@
package handlebarsjs
import (
"fmt"
"reflect"
"strings"
"testing"
"git.reinaldyrafli.com/aldy505/handlebars-go"
)
//
// Helpers
//
func barSuffixHelper(context string) string {
return "bar " + context
}
func echoHelper(str string) string {
return str
}
func echoNbHelper(str string, nb int) string {
result := ""
for i := 0; i < nb; i++ {
result += str
}
return result
}
func linkHelper(prefix string, options *handlebars.Options) string {
return fmt.Sprintf(`<a href="%s/%s">%s</a>`, prefix, options.ValueStr("url"), options.ValueStr("text"))
}
func rawHelper(options *handlebars.Options) string {
return options.Fn()
}
func rawThreeHelper(a, b, c string, options *handlebars.Options) string {
return options.Fn() + a + b + c
}
func formHelper(options *handlebars.Options) string {
return "<form>" + options.Fn() + "</form>"
}
func formCtxHelper(context interface{}, options *handlebars.Options) string {
return "<form>" + options.FnWith(context) + "</form>"
}
func listHelper(context interface{}, options *handlebars.Options) string {
val := reflect.ValueOf(context)
switch val.Kind() {
case reflect.Array, reflect.Slice:
if val.Len() > 0 {
result := "<ul>"
for i := 0; i < val.Len(); i++ {
result += "<li>"
result += options.FnWith(val.Index(i).Interface())
result += "</li>"
}
result += "</ul>"
return result
}
}
return "<p>" + options.Inverse() + "</p>"
}
func blogHelper(val string) string {
return "val is " + val
}
func equalHelper(a, b string) string {
return handlebars.Str(a == b)
}
func dashHelper(a, b string) string {
return a + "-" + b
}
func concatHelper(a, b string) string {
return a + b
}
func detectDataHelper(options *handlebars.Options) string {
if val, ok := options.DataFrame().Get("exclaim").(string); ok {
return val
}
return ""
}
// Those tests come from:
//
// https://github.com/wycats/handlebars.js/blob/master/spec/helper.js
var helpersTests = []Test{
{
"helper with complex lookup",
"{{#goodbyes}}{{{link ../prefix}}}{{/goodbyes}}",
map[string]interface{}{"prefix": "/root", "goodbyes": []map[string]string{{"text": "Goodbye", "url": "goodbye"}}},
nil,
map[string]interface{}{"link": linkHelper},
nil,
`<a href="/root/goodbye">Goodbye</a>`,
},
{
"helper for raw block gets raw content",
"{{{{raw}}}} {{test}} {{{{/raw}}}}",
map[string]interface{}{"test": "hello"},
nil,
map[string]interface{}{"raw": rawHelper},
nil,
" {{test}} ",
},
{
"helper for raw block gets parameters",
"{{{{raw 1 2 3}}}} {{test}} {{{{/raw}}}}",
map[string]interface{}{"test": "hello"},
nil,
map[string]interface{}{"raw": rawThreeHelper},
nil,
" {{test}} 123",
},
{
"helper block with complex lookup expression",
"{{#goodbyes}}{{../name}}{{/goodbyes}}",
map[string]interface{}{"name": "Alan"},
nil,
map[string]interface{}{"goodbyes": func(options *handlebars.Options) string {
out := ""
for _, str := range []string{"Goodbye", "goodbye", "GOODBYE"} {
out += str + " " + options.FnWith(str) + "! "
}
return out
}},
nil,
"Goodbye Alan! goodbye Alan! GOODBYE Alan! ",
},
{
"helper with complex lookup and nested template",
"{{#goodbyes}}{{#link ../prefix}}{{text}}{{/link}}{{/goodbyes}}",
map[string]interface{}{"prefix": "/root", "goodbyes": []map[string]string{{"text": "Goodbye", "url": "goodbye"}}},
nil,
map[string]interface{}{"link": linkHelper},
nil,
`<a href="/root/goodbye">Goodbye</a>`,
},
{
// note: The JS implementation returns undefined, we return empty string
"helper returning undefined value (1)",
" {{nothere}}",
map[string]interface{}{},
nil,
map[string]interface{}{"nothere": func() string {
return ""
}},
nil,
" ",
},
{
// note: The JS implementation returns undefined, we return empty string
"helper returning undefined value (2)",
" {{#nothere}}{{/nothere}}",
map[string]interface{}{},
nil,
map[string]interface{}{"nothere": func() string {
return ""
}},
nil,
" ",
},
{
"block helper",
"{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!",
map[string]interface{}{"world": "world"},
nil,
map[string]interface{}{"goodbyes": func(options *handlebars.Options) string {
return options.FnWith(map[string]string{"text": "GOODBYE"})
}},
nil,
"GOODBYE! cruel world!",
},
{
"block helper staying in the same context",
"{{#form}}<p>{{name}}</p>{{/form}}",
map[string]interface{}{"name": "Yehuda"},
nil,
map[string]interface{}{"form": formHelper},
nil,
"<form><p>Yehuda</p></form>",
},
{
"block helper should have context in this",
"<ul>{{#people}}<li>{{#link}}{{name}}{{/link}}</li>{{/people}}</ul>",
map[string]interface{}{"people": []map[string]interface{}{{"name": "Alan", "id": 1}, {"name": "Yehuda", "id": 2}}},
nil,
map[string]interface{}{"link": func(options *handlebars.Options) string {
return fmt.Sprintf("<a href=\"/people/%s\">%s</a>", options.ValueStr("id"), options.Fn())
}},
nil,
`<ul><li><a href="/people/1">Alan</a></li><li><a href="/people/2">Yehuda</a></li></ul>`,
},
{
"block helper for undefined value",
"{{#empty}}shouldn't render{{/empty}}",
nil, nil, nil, nil,
"",
},
{
"block helper passing a new context",
"{{#form yehuda}}<p>{{name}}</p>{{/form}}",
map[string]map[string]string{"yehuda": {"name": "Yehuda"}},
nil,
map[string]interface{}{"form": formCtxHelper},
nil,
"<form><p>Yehuda</p></form>",
},
{
"block helper passing a complex path context",
"{{#form yehuda/cat}}<p>{{name}}</p>{{/form}}",
map[string]map[string]interface{}{"yehuda": {"name": "Yehuda", "cat": map[string]string{"name": "Harold"}}},
nil,
map[string]interface{}{"form": formCtxHelper},
nil,
"<form><p>Harold</p></form>",
},
{
"nested block helpers",
"{{#form yehuda}}<p>{{name}}</p>{{#link}}Hello{{/link}}{{/form}}",
map[string]map[string]string{"yehuda": {"name": "Yehuda"}},
nil,
map[string]interface{}{"link": func(options *handlebars.Options) string {
return fmt.Sprintf("<a href=\"%s\">%s</a>", options.ValueStr("name"), options.Fn())
}, "form": formCtxHelper},
nil,
`<form><p>Yehuda</p><a href="Yehuda">Hello</a></form>`,
},
{
"block helper inverted sections (1) - an inverse wrapper is passed in as a new context",
"{{#list people}}{{name}}{{^}}<em>Nobody's here</em>{{/list}}",
map[string][]map[string]string{"people": {{"name": "Alan"}, {"name": "Yehuda"}}},
nil,
map[string]interface{}{"list": listHelper},
nil,
`<ul><li>Alan</li><li>Yehuda</li></ul>`,
},
{
"block helper inverted sections (2) - an inverse wrapper can be optionally called",
"{{#list people}}{{name}}{{^}}<em>Nobody's here</em>{{/list}}",
map[string][]map[string]string{"people": {}},
nil,
map[string]interface{}{"list": listHelper},
nil,
`<p><em>Nobody's here</em></p>`,
},
{
"block helper inverted sections (3) - the context of an inverse is the parent of the block",
"{{#list people}}Hello{{^}}{{message}}{{/list}}",
map[string]interface{}{"people": []interface{}{}, "message": "Nobody's here"},
nil,
map[string]interface{}{"list": listHelper},
nil,
`<p>Nobody&apos;s here</p>`,
},
{
"pathed lambdas with parameters (1)",
"{{./helper 1}}",
map[string]interface{}{
"helper": func(param int) string { return "winning" },
"hash": map[string]interface{}{
"helper": func(param int) string { return "winning" },
},
},
nil,
map[string]interface{}{"./helper": func(param int) string { return "fail" }},
nil,
"winning",
},
{
"pathed lambdas with parameters (2)",
"{{hash/helper 1}}",
map[string]interface{}{
"helper": func(param int) string { return "winning" },
"hash": map[string]interface{}{
"helper": func(param int) string { return "winning" },
},
},
nil,
map[string]interface{}{"./helper": func(param int) string { return "fail" }},
nil,
"winning",
},
{
"helpers hash - providing a helpers hash (1)",
"Goodbye {{cruel}} {{world}}!",
map[string]interface{}{"cruel": "cruel"},
nil,
map[string]interface{}{"world": func() string { return "world" }},
nil,
"Goodbye cruel world!",
},
{
"helpers hash - providing a helpers hash (2)",
"Goodbye {{#iter}}{{cruel}} {{world}}{{/iter}}!",
map[string]interface{}{"iter": []map[string]string{{"cruel": "cruel"}}},
nil,
map[string]interface{}{"world": func() string { return "world" }},
nil,
"Goodbye cruel world!",
},
{
"helpers hash - in cases of conflict, helpers win (1)",
"{{{lookup}}}",
map[string]interface{}{"lookup": "Explicit"},
nil,
map[string]interface{}{"lookup": func() string { return "helpers" }},
nil,
"helpers",
},
{
"helpers hash - in cases of conflict, helpers win (2)",
"{{lookup}}",
map[string]interface{}{"lookup": "Explicit"},
nil,
map[string]interface{}{"lookup": func() string { return "helpers" }},
nil,
"helpers",
},
{
"helpers hash - the helpers hash is available is nested contexts",
"{{#outer}}{{#inner}}{{helper}}{{/inner}}{{/outer}}",
map[string]interface{}{"outer": map[string]interface{}{"inner": map[string]interface{}{"unused": []string{}}}},
nil,
map[string]interface{}{"helper": func() string { return "helper" }},
nil,
"helper",
},
// @todo "helpers hash - the helper hash should augment the global hash"
// @todo "registration"
{
"decimal number literals work",
"Message: {{hello -1.2 1.2}}",
nil, nil,
map[string]interface{}{"hello": func(times, times2 interface{}) string {
ts, t2s := "NaN", "NaN"
if v, ok := times.(float64); ok {
ts = handlebars.Str(v)
}
if v, ok := times2.(float64); ok {
t2s = handlebars.Str(v)
}
return "Hello " + ts + " " + t2s + " times"
}},
nil,
"Message: Hello -1.2 1.2 times",
},
{
"negative number literals work",
"Message: {{hello -12}}",
nil, nil,
map[string]interface{}{"hello": func(times interface{}) string {
ts := "NaN"
if v, ok := times.(int); ok {
ts = handlebars.Str(v)
}
return "Hello " + ts + " times"
}},
nil,
"Message: Hello -12 times",
},
{
"String literal parameters - simple literals work",
`Message: {{hello "world" 12 true false}}`,
nil, nil,
map[string]interface{}{"hello": func(p, t, b, b2 interface{}) string {
times, bool1, bool2 := "NaN", "NaB", "NaB"
param, ok := p.(string)
if !ok {
param = "NaN"
}
if v, ok := t.(int); ok {
times = handlebars.Str(v)
}
if v, ok := b.(bool); ok {
bool1 = handlebars.Str(v)
}
if v, ok := b2.(bool); ok {
bool2 = handlebars.Str(v)
}
return "Hello " + param + " " + times + " times: " + bool1 + " " + bool2
}},
nil,
"Message: Hello world 12 times: true false",
},
// @todo "using a quote in the middle of a parameter raises an error"
{
"String literal parameters - escaping a String is possible",
"Message: {{{hello \"\\\"world\\\"\"}}}",
nil, nil,
map[string]interface{}{"hello": func(param string) string {
return "Hello " + param
}},
nil,
`Message: Hello "world"`,
},
{
"String literal parameters - it works with ' marks",
"Message: {{{hello \"Alan's world\"}}}",
nil, nil,
map[string]interface{}{"hello": func(param string) string {
return "Hello " + param
}},
nil,
`Message: Hello Alan's world`,
},
{
"multiple parameters - simple multi-params work",
"Message: {{goodbye cruel world}}",
map[string]string{"cruel": "cruel", "world": "world"},
nil,
map[string]interface{}{"goodbye": func(cruel, world string) string {
return "Goodbye " + cruel + " " + world
}},
nil,
"Message: Goodbye cruel world",
},
{
"multiple parameters - block multi-params work",
"Message: {{#goodbye cruel world}}{{greeting}} {{adj}} {{noun}}{{/goodbye}}",
map[string]string{"cruel": "cruel", "world": "world"},
nil,
map[string]interface{}{"goodbye": func(cruel, world string, options *handlebars.Options) string {
return options.FnWith(map[string]interface{}{"greeting": "Goodbye", "adj": cruel, "noun": world})
}},
nil,
"Message: Goodbye cruel world",
},
{
"hash - helpers can take an optional hash",
`{{goodbye cruel="CRUEL" world="WORLD" times=12}}`,
nil, nil,
map[string]interface{}{"goodbye": func(options *handlebars.Options) string {
return "GOODBYE " + options.HashStr("cruel") + " " + options.HashStr("world") + " " + options.HashStr("times") + " TIMES"
}},
nil,
"GOODBYE CRUEL WORLD 12 TIMES",
},
{
"hash - helpers can take an optional hash with booleans (1)",
`{{goodbye cruel="CRUEL" world="WORLD" print=true}}`,
nil, nil,
map[string]interface{}{"goodbye": func(options *handlebars.Options) string {
p, ok := options.HashProp("print").(bool)
if ok {
if p {
return "GOODBYE " + options.HashStr("cruel") + " " + options.HashStr("world")
}
return "NOT PRINTING"
}
return "THIS SHOULD NOT HAPPEN"
}},
nil,
"GOODBYE CRUEL WORLD",
},
{
"hash - helpers can take an optional hash with booleans (2)",
`{{goodbye cruel="CRUEL" world="WORLD" print=false}}`,
nil, nil,
map[string]interface{}{"goodbye": func(options *handlebars.Options) string {
p, ok := options.HashProp("print").(bool)
if ok {
if p {
return "GOODBYE " + options.HashStr("cruel") + " " + options.HashStr("world")
}
return "NOT PRINTING"
}
return "THIS SHOULD NOT HAPPEN"
}},
nil,
"NOT PRINTING",
},
{
"block helpers can take an optional hash",
`{{#goodbye cruel="CRUEL" times=12}}world{{/goodbye}}`,
nil, nil,
map[string]interface{}{"goodbye": func(options *handlebars.Options) string {
return "GOODBYE " + options.HashStr("cruel") + " " + options.Fn() + " " + options.HashStr("times") + " TIMES"
}},
nil,
"GOODBYE CRUEL world 12 TIMES",
},
{
"block helpers can take an optional hash with single quoted stings",
`{{#goodbye cruel='CRUEL' times=12}}world{{/goodbye}}`,
nil, nil,
map[string]interface{}{"goodbye": func(options *handlebars.Options) string {
return "GOODBYE " + options.HashStr("cruel") + " " + options.Fn() + " " + options.HashStr("times") + " TIMES"
}},
nil,
"GOODBYE CRUEL world 12 TIMES",
},
{
"block helpers can take an optional hash with booleans (1)",
`{{#goodbye cruel="CRUEL" print=true}}world{{/goodbye}}`,
nil, nil,
map[string]interface{}{"goodbye": func(options *handlebars.Options) string {
p, ok := options.HashProp("print").(bool)
if ok {
if p {
return "GOODBYE " + options.HashStr("cruel") + " " + options.Fn()
}
return "NOT PRINTING"
}
return "THIS SHOULD NOT HAPPEN"
}},
nil,
"GOODBYE CRUEL world",
},
{
"block helpers can take an optional hash with booleans (1)",
`{{#goodbye cruel="CRUEL" print=false}}world{{/goodbye}}`,
nil, nil,
map[string]interface{}{"goodbye": func(options *handlebars.Options) string {
p, ok := options.HashProp("print").(bool)
if ok {
if p {
return "GOODBYE " + options.HashStr("cruel") + " " + options.Fn()
}
return "NOT PRINTING"
}
return "THIS SHOULD NOT HAPPEN"
}},
nil,
"NOT PRINTING",
},
// @todo "helperMissing - if a context is not found, helperMissing is used" throw error
// @todo "helperMissing - if a context is not found, custom helperMissing is used"
// @todo "helperMissing - if a value is not found, custom helperMissing is used"
{
"block helpers can take an optional hash with booleans (1)",
`{{#goodbye cruel="CRUEL" print=false}}world{{/goodbye}}`,
nil, nil,
map[string]interface{}{"goodbye": func(options *handlebars.Options) string {
p, ok := options.HashProp("print").(bool)
if ok {
if p {
return "GOODBYE " + options.HashStr("cruel") + " " + options.Fn()
}
return "NOT PRINTING"
}
return "THIS SHOULD NOT HAPPEN"
}},
nil,
"NOT PRINTING",
},
// @todo "knownHelpers/knownHelpersOnly" tests
// @todo "blockHelperMissing" tests
// @todo "name field" tests
{
"name conflicts - helpers take precedence over same-named context properties",
`{{goodbye}} {{cruel world}}`,
map[string]string{"goodbye": "goodbye", "world": "world"},
nil,
map[string]interface{}{
"goodbye": func(options *handlebars.Options) string {
return strings.ToUpper(options.ValueStr("goodbye"))
},
"cruel": func(world string) string {
return "cruel " + strings.ToUpper(world)
},
},
nil,
"GOODBYE cruel WORLD",
},
{
"name conflicts - helpers take precedence over same-named context properties",
`{{#goodbye}} {{cruel world}}{{/goodbye}}`,
map[string]string{"goodbye": "goodbye", "world": "world"},
nil,
map[string]interface{}{
"goodbye": func(options *handlebars.Options) string {
return strings.ToUpper(options.ValueStr("goodbye")) + options.Fn()
},
"cruel": func(world string) string {
return "cruel " + strings.ToUpper(world)
},
},
nil,
"GOODBYE cruel WORLD",
},
{
"name conflicts - Scoped names take precedence over helpers",
`{{this.goodbye}} {{cruel world}} {{cruel this.goodbye}}`,
map[string]string{"goodbye": "goodbye", "world": "world"},
nil,
map[string]interface{}{
"goodbye": func(options *handlebars.Options) string {
return strings.ToUpper(options.ValueStr("goodbye"))
},
"cruel": func(world string) string {
return "cruel " + strings.ToUpper(world)
},
},
nil,
"goodbye cruel WORLD cruel GOODBYE",
},
{
"name conflicts - Scoped names take precedence over block helpers",
`{{#goodbye}} {{cruel world}}{{/goodbye}} {{this.goodbye}}`,
map[string]string{"goodbye": "goodbye", "world": "world"},
nil,
map[string]interface{}{
"goodbye": func(options *handlebars.Options) string {
return strings.ToUpper(options.ValueStr("goodbye")) + options.Fn()
},
"cruel": func(world string) string {
return "cruel " + strings.ToUpper(world)
},
},
nil,
"GOODBYE cruel WORLD goodbye",
},
// @todo "block params" tests
}
func TestHelpers(t *testing.T) {
launchTests(t, helpersTests)
}

View File

@ -0,0 +1,181 @@
package handlebarsjs
import "testing"
// Those tests come from:
//
// https://github.com/wycats/handlebars.js/blob/master/spec/partials.js
var partialsTests = []Test{
{
"basic partials",
"Dudes: {{#dudes}}{{> dude}}{{/dudes}}",
map[string]interface{}{"dudes": []map[string]string{{"name": "Yehuda", "url": "http://yehuda"}, {"name": "Alan", "url": "http://alan"}}},
nil, nil,
map[string]string{"dude": "{{name}} ({{url}}) "},
"Dudes: Yehuda (http://yehuda) Alan (http://alan) ",
},
{
"dynamic partials",
"Dudes: {{#dudes}}{{> (partial)}}{{/dudes}}",
map[string]interface{}{"dudes": []map[string]string{{"name": "Yehuda", "url": "http://yehuda"}, {"name": "Alan", "url": "http://alan"}}},
nil,
map[string]interface{}{"partial": func() string {
return "dude"
}},
map[string]string{"dude": "{{name}} ({{url}}) "},
"Dudes: Yehuda (http://yehuda) Alan (http://alan) ",
},
// @todo "failing dynamic partials"
{
"partials with context",
"Dudes: {{>dude dudes}}",
map[string]interface{}{"dudes": []map[string]string{{"name": "Yehuda", "url": "http://yehuda"}, {"name": "Alan", "url": "http://alan"}}},
nil, nil,
map[string]string{"dude": "{{#this}}{{name}} ({{url}}) {{/this}}"},
"Dudes: Yehuda (http://yehuda) Alan (http://alan) ",
},
{
"partials with undefined context",
"Dudes: {{>dude dudes}}",
map[string]interface{}{},
nil, nil,
map[string]string{"dude": "{{foo}} Empty"},
"Dudes: Empty",
},
// @todo "partials with duplicate parameters"
{
"partials with parameters",
"Dudes: {{#dudes}}{{> dude others=..}}{{/dudes}}",
map[string]interface{}{"foo": "bar", "dudes": []map[string]string{{"name": "Yehuda", "url": "http://yehuda"}, {"name": "Alan", "url": "http://alan"}}},
nil, nil,
map[string]string{"dude": "{{others.foo}}{{name}} ({{url}}) "},
"Dudes: barYehuda (http://yehuda) barAlan (http://alan) ",
},
{
"partial in a partial",
"Dudes: {{#dudes}}{{>dude}}{{/dudes}}",
map[string]interface{}{"dudes": []map[string]string{{"name": "Yehuda", "url": "http://yehuda"}, {"name": "Alan", "url": "http://alan"}}},
nil, nil,
map[string]string{"dude": "{{name}} {{> url}} ", "url": `<a href="{{url}}">{{url}}</a>`},
`Dudes: Yehuda <a href="http://yehuda">http://yehuda</a> Alan <a href="http://alan">http://alan</a> `,
},
// @todo "rendering undefined partial throws an exception"
// @todo "registering undefined partial throws an exception"
// SKIP: "rendering template partial in vm mode throws an exception"
// SKIP: "rendering function partial in vm mode"
{
"GH-14: a partial preceding a selector",
"Dudes: {{>dude}} {{anotherDude}}",
map[string]string{"name": "Jeepers", "anotherDude": "Creepers"},
nil, nil,
map[string]string{"dude": "{{name}}"},
"Dudes: Jeepers Creepers",
},
{
"Partials with slash paths",
"Dudes: {{> shared/dude}}",
map[string]string{"name": "Jeepers", "anotherDude": "Creepers"},
nil, nil,
map[string]string{"shared/dude": "{{name}}"},
"Dudes: Jeepers",
},
{
"Partials with slash and point paths",
"Dudes: {{> shared/dude.thing}}",
map[string]string{"name": "Jeepers", "anotherDude": "Creepers"},
nil, nil,
map[string]string{"shared/dude.thing": "{{name}}"},
"Dudes: Jeepers",
},
// @todo "Global Partials"
// @todo "Multiple partial registration"
{
"Partials with integer path",
"Dudes: {{> 404}}",
map[string]string{"name": "Jeepers", "anotherDude": "Creepers"},
nil, nil,
map[string]string{"404": "{{name}}"}, // @note Difference with JS test: partial name is a string
"Dudes: Jeepers",
},
// @note This is not supported by our implementation. But really... who cares ?
// {
// "Partials with complex path",
// "Dudes: {{> 404/asdf?.bar}}",
// map[string]string{"name": "Jeepers", "anotherDude": "Creepers"},
// nil, nil,
// map[string]string{"404/asdf?.bar": "{{name}}"},
// "Dudes: Jeepers",
// },
{
"Partials with escaped",
"Dudes: {{> [+404/asdf?.bar]}}",
map[string]string{"name": "Jeepers", "anotherDude": "Creepers"},
nil, nil,
map[string]string{"+404/asdf?.bar": "{{name}}"},
"Dudes: Jeepers",
},
{
"Partials with string",
"Dudes: {{> '+404/asdf?.bar'}}",
map[string]string{"name": "Jeepers", "anotherDude": "Creepers"},
nil, nil,
map[string]string{"+404/asdf?.bar": "{{name}}"},
"Dudes: Jeepers",
},
{
"should handle empty partial",
"Dudes: {{#dudes}}{{> dude}}{{/dudes}}",
map[string]interface{}{"dudes": []map[string]string{{"name": "Yehuda", "url": "http://yehuda"}, {"name": "Alan", "url": "http://alan"}}},
nil, nil,
map[string]string{"dude": ""},
"Dudes: ",
},
// @todo "throw on missing partial"
// SKIP: "should pass compiler flags"
{
"standalone partials (1) - indented partials",
"Dudes:\n{{#dudes}}\n {{>dude}}\n{{/dudes}}",
map[string]interface{}{"dudes": []map[string]string{{"name": "Yehuda", "url": "http://yehuda"}, {"name": "Alan", "url": "http://alan"}}},
nil, nil,
map[string]string{"dude": "{{name}}\n"},
"Dudes:\n Yehuda\n Alan\n",
},
{
"standalone partials (2) - nested indented partials",
"Dudes:\n{{#dudes}}\n {{>dude}}\n{{/dudes}}",
map[string]interface{}{"dudes": []map[string]string{{"name": "Yehuda", "url": "http://yehuda"}, {"name": "Alan", "url": "http://alan"}}},
nil, nil,
map[string]string{"dude": "{{name}}\n {{> url}}", "url": "{{url}}!\n"},
"Dudes:\n Yehuda\n http://yehuda!\n Alan\n http://alan!\n",
},
// // @todo preventIndent option
// {
// "standalone partials (3) - prevent nested indented partials",
// "Dudes:\n{{#dudes}}\n {{>dude}}\n{{/dudes}}",
// map[string]interface{}{"dudes": []map[string]string{{"name": "Yehuda", "url": "http://yehuda"}, {"name": "Alan", "url": "http://alan"}}},
// nil, nil,
// map[string]string{"dude": "{{name}}\n {{> url}}", "url": "{{url}}!\n"},
// "Dudes:\n Yehuda\n http://yehuda!\n Alan\n http://alan!\n",
// },
// @todo "compat mode"
}
func TestPartials(t *testing.T) {
launchTests(t, partialsTests)
}

View File

@ -0,0 +1,208 @@
package handlebarsjs
import (
"testing"
"git.reinaldyrafli.com/aldy505/handlebars-go"
)
// Those tests come from:
//
// https://github.com/wycats/handlebars.js/blob/master/spec/subexpression.js
var subexpressionsTests = []Test{
{
"arg-less helper",
"{{foo (bar)}}!",
map[string]interface{}{},
nil,
map[string]interface{}{
"foo": func(val string) string {
return val + val
},
"bar": func() string {
return "LOL"
},
},
nil,
"LOLLOL!",
},
{
"helper w args",
"{{blog (equal a b)}}",
map[string]interface{}{"bar": "LOL"},
nil,
map[string]interface{}{
"blog": blogHelper,
"equal": equalHelper,
},
nil,
"val is true",
},
{
"mixed paths and helpers",
"{{blog baz.bat (equal a b) baz.bar}}",
map[string]interface{}{"bar": "LOL", "baz": map[string]string{"bat": "foo!", "bar": "bar!"}},
nil,
map[string]interface{}{
"blog": func(p, p2, p3 string) string {
return "val is " + p + ", " + p2 + " and " + p3
},
"equal": equalHelper,
},
nil,
"val is foo!, true and bar!",
},
{
"supports much nesting",
"{{blog (equal (equal true true) true)}}",
map[string]interface{}{"bar": "LOL"},
nil,
map[string]interface{}{
"blog": blogHelper,
"equal": equalHelper,
},
nil,
"val is true",
},
{
"GH-800 : Complex subexpressions (1)",
"{{dash 'abc' (concat a b)}}",
map[string]interface{}{"a": "a", "b": "b", "c": map[string]string{"c": "c"}, "d": "d", "e": map[string]string{"e": "e"}},
nil,
map[string]interface{}{"dash": dashHelper, "concat": concatHelper},
nil,
"abc-ab",
},
{
"GH-800 : Complex subexpressions (2)",
"{{dash d (concat a b)}}",
map[string]interface{}{"a": "a", "b": "b", "c": map[string]string{"c": "c"}, "d": "d", "e": map[string]string{"e": "e"}},
nil,
map[string]interface{}{"dash": dashHelper, "concat": concatHelper},
nil,
"d-ab",
},
{
"GH-800 : Complex subexpressions (3)",
"{{dash c.c (concat a b)}}",
map[string]interface{}{"a": "a", "b": "b", "c": map[string]string{"c": "c"}, "d": "d", "e": map[string]string{"e": "e"}},
nil,
map[string]interface{}{"dash": dashHelper, "concat": concatHelper},
nil,
"c-ab",
},
{
"GH-800 : Complex subexpressions (4)",
"{{dash (concat a b) c.c}}",
map[string]interface{}{"a": "a", "b": "b", "c": map[string]string{"c": "c"}, "d": "d", "e": map[string]string{"e": "e"}},
nil,
map[string]interface{}{"dash": dashHelper, "concat": concatHelper},
nil,
"ab-c",
},
{
"GH-800 : Complex subexpressions (5)",
"{{dash (concat a e.e) c.c}}",
map[string]interface{}{"a": "a", "b": "b", "c": map[string]string{"c": "c"}, "d": "d", "e": map[string]string{"e": "e"}},
nil,
map[string]interface{}{"dash": dashHelper, "concat": concatHelper},
nil,
"ae-c",
},
{
// note: test not relevant
"provides each nested helper invocation its own options hash",
"{{equal (equal true true) true}}",
map[string]interface{}{},
nil,
map[string]interface{}{
"equal": equalHelper,
},
nil,
"true",
},
{
"with hashes",
"{{blog (equal (equal true true) true fun='yes')}}",
map[string]interface{}{"bar": "LOL"},
nil,
map[string]interface{}{
"blog": blogHelper,
"equal": equalHelper,
},
nil,
"val is true",
},
{
"as hashes",
"{{blog fun=(equal (blog fun=1) 'val is 1')}}",
map[string]interface{}{},
nil,
map[string]interface{}{
"blog": func(options *handlebars.Options) string {
return "val is " + options.HashStr("fun")
},
"equal": equalHelper,
},
nil,
"val is true",
},
{
"multiple subexpressions in a hash",
`{{input aria-label=(t "Name") placeholder=(t "Example User")}}`,
map[string]interface{}{},
nil,
map[string]interface{}{
"input": func(options *handlebars.Options) handlebars.SafeString {
return handlebars.SafeString(`<input aria-label="` + options.HashStr("aria-label") + `" placeholder="` + options.HashStr("placeholder") + `" />`)
},
"t": func(param string) handlebars.SafeString {
return handlebars.SafeString(param)
},
},
nil,
`<input aria-label="Name" placeholder="Example User" />`,
},
{
"multiple subexpressions in a hash with context",
`{{input aria-label=(t item.field) placeholder=(t item.placeholder)}}`,
map[string]map[string]string{"item": {"field": "Name", "placeholder": "Example User"}},
nil,
map[string]interface{}{
"input": func(options *handlebars.Options) handlebars.SafeString {
return handlebars.SafeString(`<input aria-label="` + options.HashStr("aria-label") + `" placeholder="` + options.HashStr("placeholder") + `" />`)
},
"t": func(param string) handlebars.SafeString {
return handlebars.SafeString(param)
},
},
nil,
`<input aria-label="Name" placeholder="Example User" />`,
},
// @todo "in string params mode"
// @todo "as hashes in string params mode"
{
"subexpression functions on the context",
"{{foo (bar)}}!",
map[string]interface{}{"bar": func() string { return "LOL" }},
nil,
map[string]interface{}{
"foo": func(val string) string {
return val + val
},
},
nil,
"LOLLOL!",
},
// @todo "subexpressions can't just be property lookups" should raise error
}
func TestSubexpressions(t *testing.T) {
launchTests(t, subexpressionsTests)
}

View File

@ -0,0 +1,258 @@
package handlebarsjs
import "testing"
// Those tests come from:
//
// https://github.com/wycats/handlebars.js/blob/master/spec/whitespace-control.js
var whitespaceControlTests = []Test{
{
"should strip whitespace around mustache calls (1)",
" {{~foo~}} ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar&lt;",
},
{
"should strip whitespace around mustache calls (2)",
" {{~foo}} ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar&lt; ",
},
{
"should strip whitespace around mustache calls (3)",
" {{foo~}} ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
" bar&lt;",
},
{
"should strip whitespace around mustache calls (4)",
" {{~&foo~}} ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar<",
},
{
"should strip whitespace around mustache calls (5)",
" {{~{foo}~}} ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar<",
},
{
"should strip whitespace around mustache calls (6)",
"1\n{{foo~}} \n\n 23\n{{bar}}4",
nil, nil, nil, nil,
"1\n23\n4",
},
{
"blocks - should strip whitespace around simple block calls (1)",
" {{~#if foo~}} bar {{~/if~}} ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar",
},
{
"blocks - should strip whitespace around simple block calls (2)",
" {{#if foo~}} bar {{/if~}} ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
" bar ",
},
{
"blocks - should strip whitespace around simple block calls (3)",
" {{~#if foo}} bar {{~/if}} ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
" bar ",
},
{
"blocks - should strip whitespace around simple block calls (4)",
" {{#if foo}} bar {{/if}} ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
" bar ",
},
{
"blocks - should strip whitespace around simple block calls (5)",
" \n\n{{~#if foo~}} \n\nbar \n\n{{~/if~}}\n\n ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar",
},
{
"blocks - should strip whitespace around simple block calls (6)",
" a\n\n{{~#if foo~}} \n\nbar \n\n{{~/if~}}\n\na ",
map[string]string{"foo": "bar<"},
nil, nil, nil,
" abara ",
},
{
"should strip whitespace around inverse block calls (1)",
" {{~^if foo~}} bar {{~/if~}} ",
nil, nil, nil, nil,
"bar",
},
{
"should strip whitespace around inverse block calls (2)",
" {{^if foo~}} bar {{/if~}} ",
nil, nil, nil, nil,
" bar ",
},
{
"should strip whitespace around inverse block calls (3)",
" {{~^if foo}} bar {{~/if}} ",
nil, nil, nil, nil,
" bar ",
},
{
"should strip whitespace around inverse block calls (4)",
" {{^if foo}} bar {{/if}} ",
nil, nil, nil, nil,
" bar ",
},
{
"should strip whitespace around inverse block calls (5)",
" \n\n{{~^if foo~}} \n\nbar \n\n{{~/if~}}\n\n ",
nil, nil, nil, nil,
"bar",
},
{
"should strip whitespace around complex block calls (1)",
"{{#if foo~}} bar {{~^~}} baz {{~/if}}",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar",
},
{
"should strip whitespace around complex block calls (2)",
"{{#if foo~}} bar {{^~}} baz {{/if}}",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar ",
},
{
"should strip whitespace around complex block calls (3)",
"{{#if foo}} bar {{~^~}} baz {{~/if}}",
map[string]string{"foo": "bar<"},
nil, nil, nil,
" bar",
},
{
"should strip whitespace around complex block calls (4)",
"{{#if foo}} bar {{^~}} baz {{/if}}",
map[string]string{"foo": "bar<"},
nil, nil, nil,
" bar ",
},
{
"should strip whitespace around complex block calls (5)",
"{{#if foo~}} bar {{~else~}} baz {{~/if}}",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar",
},
{
"should strip whitespace around complex block calls (6)",
"\n\n{{~#if foo~}} \n\nbar \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar",
},
{
"should strip whitespace around complex block calls (7)",
"\n\n{{~#if foo~}} \n\n{{{foo}}} \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n",
map[string]string{"foo": "bar<"},
nil, nil, nil,
"bar<",
},
{
"should strip whitespace around complex block calls (8)",
"{{#if foo~}} bar {{~^~}} baz {{~/if}}",
nil, nil, nil, nil,
"baz",
},
{
"should strip whitespace around complex block calls (9)",
"{{#if foo}} bar {{~^~}} baz {{/if}}",
nil, nil, nil, nil,
"baz ",
},
{
"should strip whitespace around complex block calls (10)",
"{{#if foo~}} bar {{~^}} baz {{~/if}}",
nil, nil, nil, nil,
" baz",
},
{
"should strip whitespace around complex block calls (11)",
"{{#if foo~}} bar {{~^}} baz {{/if}}",
nil, nil, nil, nil,
" baz ",
},
{
"should strip whitespace around complex block calls (12)",
"{{#if foo~}} bar {{~else~}} baz {{~/if}}",
nil, nil, nil, nil,
"baz",
},
{
"should strip whitespace around complex block calls (13)",
"\n\n{{~#if foo~}} \n\nbar \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n",
nil, nil, nil, nil,
"baz",
},
{
"should strip whitespace around partials (1)",
"foo {{~> dude~}} ",
nil, nil, nil,
map[string]string{"dude": "bar"},
"foobar",
},
{
"should strip whitespace around partials (2)",
"foo {{> dude~}} ",
nil, nil, nil,
map[string]string{"dude": "bar"},
"foo bar",
},
{
"should strip whitespace around partials (3)",
"foo {{> dude}} ",
nil, nil, nil,
map[string]string{"dude": "bar"},
"foo bar ",
},
{
"should strip whitespace around partials (4)",
"foo\n {{~> dude}} ",
nil, nil, nil,
map[string]string{"dude": "bar"},
"foobar",
},
{
"should strip whitespace around partials (5)",
"foo\n {{> dude}} ",
nil, nil, nil,
map[string]string{"dude": "bar"},
"foo\n bar",
},
{
"should only strip whitespace once",
" {{~foo~}} {{foo}} {{foo}} ",
map[string]string{"foo": "bar"},
nil, nil, nil,
"barbar bar ",
},
}
func TestWhitespaceControl(t *testing.T) {
launchTests(t, whitespaceControlTests)
}

639
lexer/lexer.go Normal file
View File

@ -0,0 +1,639 @@
// Package lexer provides a handlebars tokenizer.
package lexer
import (
"fmt"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// References:
// - https://github.com/wycats/handlebars.js/blob/master/src/handlebars.l
// - https://github.com/golang/go/blob/master/src/text/template/parse/lex.go
const (
// Mustaches detection
escapedEscapedOpenMustache = "\\\\{{"
escapedOpenMustache = "\\{{"
openMustache = "{{"
closeMustache = "}}"
closeStripMustache = "~}}"
closeUnescapedStripMustache = "}~}}"
)
const eof = -1
// lexFunc represents a function that returns the next lexer function.
type lexFunc func(*Lexer) lexFunc
// Lexer is a lexical analyzer.
type Lexer struct {
input string // input to scan
name string // lexer name, used for testing purpose
tokens chan Token // channel of scanned tokens
nextFunc lexFunc // the next function to execute
pos int // current byte position in input string
line int // current line position in input string
width int // size of last rune scanned from input string
start int // start position of the token we are scanning
// the shameful contextual properties needed because `nextFunc` is not enough
closeComment *regexp.Regexp // regexp to scan close of current comment
rawBlock bool // are we parsing a raw block content ?
}
var (
lookheadChars = `[\s` + regexp.QuoteMeta("=~}/)|") + `]`
literalLookheadChars = `[\s` + regexp.QuoteMeta("~})") + `]`
// characters not allowed in an identifier
unallowedIDChars = " \n\t!\"#%&'()*+,./;<=>@[\\]^`{|}~"
// regular expressions
rID = regexp.MustCompile(`^[^` + regexp.QuoteMeta(unallowedIDChars) + `]+`)
rDotID = regexp.MustCompile(`^\.` + lookheadChars)
rTrue = regexp.MustCompile(`^true` + literalLookheadChars)
rFalse = regexp.MustCompile(`^false` + literalLookheadChars)
rOpenRaw = regexp.MustCompile(`^\{\{\{\{`)
rCloseRaw = regexp.MustCompile(`^\}\}\}\}`)
rOpenEndRaw = regexp.MustCompile(`^\{\{\{\{/`)
rOpenEndRawLookAhead = regexp.MustCompile(`\{\{\{\{/`)
rOpenUnescaped = regexp.MustCompile(`^\{\{~?\{`)
rCloseUnescaped = regexp.MustCompile(`^\}~?\}\}`)
rOpenBlock = regexp.MustCompile(`^\{\{~?#`)
rOpenEndBlock = regexp.MustCompile(`^\{\{~?/`)
rOpenPartial = regexp.MustCompile(`^\{\{~?>`)
// {{^}} or {{else}}
rInverse = regexp.MustCompile(`^(\{\{~?\^\s*~?\}\}|\{\{~?\s*else\s*~?\}\})`)
rOpenInverse = regexp.MustCompile(`^\{\{~?\^`)
rOpenInverseChain = regexp.MustCompile(`^\{\{~?\s*else`)
// {{ or {{&
rOpen = regexp.MustCompile(`^\{\{~?&?`)
rClose = regexp.MustCompile(`^~?\}\}`)
rOpenBlockParams = regexp.MustCompile(`^as\s+\|`)
// {{!-- ... --}}
rOpenCommentDash = regexp.MustCompile(`^\{\{~?!--\s*`)
rCloseCommentDash = regexp.MustCompile(`^\s*--~?\}\}`)
// {{! ... }}
rOpenComment = regexp.MustCompile(`^\{\{~?!\s*`)
rCloseComment = regexp.MustCompile(`^\s*~?\}\}`)
)
// Scan scans given input.
//
// Tokens can then be fetched sequentially thanks to NextToken() function on returned lexer.
func Scan(input string) *Lexer {
return scanWithName(input, "")
}
// scanWithName scans given input, with a name used for testing
//
// Tokens can then be fetched sequentially thanks to NextToken() function on returned lexer.
func scanWithName(input string, name string) *Lexer {
result := &Lexer{
input: input,
name: name,
tokens: make(chan Token),
line: 1,
}
go result.run()
return result
}
// Collect scans and collect all tokens.
//
// This should be used for debugging purpose only. You should use Scan() and lexer.NextToken() functions instead.
func Collect(input string) []Token {
var result []Token
l := Scan(input)
for {
token := l.NextToken()
result = append(result, token)
if token.Kind == TokenEOF || token.Kind == TokenError {
break
}
}
return result
}
// NextToken returns the next scanned token.
func (l *Lexer) NextToken() Token {
result := <-l.tokens
return result
}
// run starts lexical analysis
func (l *Lexer) run() {
for l.nextFunc = lexContent; l.nextFunc != nil; {
l.nextFunc = l.nextFunc(l)
}
}
// next returns next character from input, or eof of there is nothing left to scan
func (l *Lexer) next() rune {
if l.pos >= len(l.input) {
l.width = 0
return eof
}
r, w := utf8.DecodeRuneInString(l.input[l.pos:])
l.width = w
l.pos += l.width
return r
}
func (l *Lexer) produce(kind TokenKind, val string) {
l.tokens <- Token{kind, val, l.start, l.line}
// scanning a new token
l.start = l.pos
// update line number
l.line += strings.Count(val, "\n")
}
// emit emits a new scanned token
func (l *Lexer) emit(kind TokenKind) {
l.produce(kind, l.input[l.start:l.pos])
}
// emitContent emits scanned content
func (l *Lexer) emitContent() {
if l.pos > l.start {
l.emit(TokenContent)
}
}
// emitString emits a scanned string
func (l *Lexer) emitString(delimiter rune) {
str := l.input[l.start:l.pos]
// replace escaped delimiters
str = strings.Replace(str, "\\"+string(delimiter), string(delimiter), -1)
l.produce(TokenString, str)
}
// peek returns but does not consume the next character in the input
func (l *Lexer) peek() rune {
r := l.next()
l.backup()
return r
}
// backup steps back one character
//
// WARNING: Can only be called once per call of next
func (l *Lexer) backup() {
l.pos -= l.width
}
// ignoreskips all characters that have been scanned up to current position
func (l *Lexer) ignore() {
l.start = l.pos
}
// accept scans the next character if it is included in given string
func (l *Lexer) accept(valid string) bool {
if strings.IndexRune(valid, l.next()) >= 0 {
return true
}
l.backup()
return false
}
// acceptRun scans all following characters that are part of given string
func (l *Lexer) acceptRun(valid string) {
for strings.IndexRune(valid, l.next()) >= 0 {
}
l.backup()
}
// errorf emits an error token
func (l *Lexer) errorf(format string, args ...interface{}) lexFunc {
l.tokens <- Token{TokenError, fmt.Sprintf(format, args...), l.start, l.line}
return nil
}
// isString returns true if content at current scanning position starts with given string
func (l *Lexer) isString(str string) bool {
return strings.HasPrefix(l.input[l.pos:], str)
}
// findRegexp returns the first string from current scanning position that matches given regular expression
func (l *Lexer) findRegexp(r *regexp.Regexp) string {
return r.FindString(l.input[l.pos:])
}
// indexRegexp returns the index of the first string from current scanning position that matches given regular expression
//
// It returns -1 if not found
func (l *Lexer) indexRegexp(r *regexp.Regexp) int {
loc := r.FindStringIndex(l.input[l.pos:])
if loc == nil {
return -1
}
return loc[0]
}
// lexContent scans content (ie: not between mustaches)
func lexContent(l *Lexer) lexFunc {
var next lexFunc
if l.rawBlock {
if i := l.indexRegexp(rOpenEndRawLookAhead); i != -1 {
// {{{{/
l.rawBlock = false
l.pos += i
next = lexOpenMustache
} else {
return l.errorf("Unclosed raw block")
}
} else if l.isString(escapedEscapedOpenMustache) {
// \\{{
// emit content with only one escaped escape
l.next()
l.emitContent()
// ignore second escaped escape
l.next()
l.ignore()
next = lexContent
} else if l.isString(escapedOpenMustache) {
// \{{
next = lexEscapedOpenMustache
} else if str := l.findRegexp(rOpenCommentDash); str != "" {
// {{!--
l.closeComment = rCloseCommentDash
next = lexComment
} else if str := l.findRegexp(rOpenComment); str != "" {
// {{!
l.closeComment = rCloseComment
next = lexComment
} else if l.isString(openMustache) {
// {{
next = lexOpenMustache
}
if next != nil {
// emit scanned content
l.emitContent()
// scan next token
return next
}
// scan next rune
if l.next() == eof {
// emit scanned content
l.emitContent()
// this is over
l.emit(TokenEOF)
return nil
}
// continue content scanning
return lexContent
}
// lexEscapedOpenMustache scans \{{
func lexEscapedOpenMustache(l *Lexer) lexFunc {
// ignore escape character
l.next()
l.ignore()
// scan mustaches
for l.peek() == '{' {
l.next()
}
return lexContent
}
// lexOpenMustache scans {{
func lexOpenMustache(l *Lexer) lexFunc {
var str string
var tok TokenKind
nextFunc := lexExpression
if str = l.findRegexp(rOpenEndRaw); str != "" {
tok = TokenOpenEndRawBlock
} else if str = l.findRegexp(rOpenRaw); str != "" {
tok = TokenOpenRawBlock
l.rawBlock = true
} else if str = l.findRegexp(rOpenUnescaped); str != "" {
tok = TokenOpenUnescaped
} else if str = l.findRegexp(rOpenBlock); str != "" {
tok = TokenOpenBlock
} else if str = l.findRegexp(rOpenEndBlock); str != "" {
tok = TokenOpenEndBlock
} else if str = l.findRegexp(rOpenPartial); str != "" {
tok = TokenOpenPartial
} else if str = l.findRegexp(rInverse); str != "" {
tok = TokenInverse
nextFunc = lexContent
} else if str = l.findRegexp(rOpenInverse); str != "" {
tok = TokenOpenInverse
} else if str = l.findRegexp(rOpenInverseChain); str != "" {
tok = TokenOpenInverseChain
} else if str = l.findRegexp(rOpen); str != "" {
tok = TokenOpen
} else {
// this is rotten
panic("Current pos MUST be an opening mustache")
}
l.pos += len(str)
l.emit(tok)
return nextFunc
}
// lexCloseMustache scans }} or ~}}
func lexCloseMustache(l *Lexer) lexFunc {
var str string
var tok TokenKind
if str = l.findRegexp(rCloseRaw); str != "" {
// }}}}
tok = TokenCloseRawBlock
} else if str = l.findRegexp(rCloseUnescaped); str != "" {
// }}}
tok = TokenCloseUnescaped
} else if str = l.findRegexp(rClose); str != "" {
// }}
tok = TokenClose
} else {
// this is rotten
panic("Current pos MUST be a closing mustache")
}
l.pos += len(str)
l.emit(tok)
return lexContent
}
// lexExpression scans inside mustaches
func lexExpression(l *Lexer) lexFunc {
// search close mustache delimiter
if l.isString(closeMustache) || l.isString(closeStripMustache) || l.isString(closeUnescapedStripMustache) {
return lexCloseMustache
}
// search some patterns before advancing scanning position
// "as |"
if str := l.findRegexp(rOpenBlockParams); str != "" {
l.pos += len(str)
l.emit(TokenOpenBlockParams)
return lexExpression
}
// ..
if l.isString("..") {
l.pos += len("..")
l.emit(TokenID)
return lexExpression
}
// .
if str := l.findRegexp(rDotID); str != "" {
l.pos += len(".")
l.emit(TokenID)
return lexExpression
}
// true
if str := l.findRegexp(rTrue); str != "" {
l.pos += len("true")
l.emit(TokenBoolean)
return lexExpression
}
// false
if str := l.findRegexp(rFalse); str != "" {
l.pos += len("false")
l.emit(TokenBoolean)
return lexExpression
}
// let's scan next character
switch r := l.next(); {
case r == eof:
return l.errorf("Unclosed expression")
case isIgnorable(r):
return lexIgnorable
case r == '(':
l.emit(TokenOpenSexpr)
case r == ')':
l.emit(TokenCloseSexpr)
case r == '=':
l.emit(TokenEquals)
case r == '@':
l.emit(TokenData)
case r == '"' || r == '\'':
l.backup()
return lexString
case r == '/' || r == '.':
l.emit(TokenSep)
case r == '|':
l.emit(TokenCloseBlockParams)
case r == '+' || r == '-' || (r >= '0' && r <= '9'):
l.backup()
return lexNumber
case r == '[':
return lexPathLiteral
case strings.IndexRune(unallowedIDChars, r) < 0:
l.backup()
return lexIdentifier
default:
return l.errorf("Unexpected character in expression: '%c'", r)
}
return lexExpression
}
// lexComment scans {{!-- or {{!
func lexComment(l *Lexer) lexFunc {
if str := l.findRegexp(l.closeComment); str != "" {
l.pos += len(str)
l.emit(TokenComment)
return lexContent
}
if r := l.next(); r == eof {
return l.errorf("Unclosed comment")
}
return lexComment
}
// lexIgnorable scans all following ignorable characters
func lexIgnorable(l *Lexer) lexFunc {
for isIgnorable(l.peek()) {
l.next()
}
l.ignore()
return lexExpression
}
// lexString scans a string
func lexString(l *Lexer) lexFunc {
// get string delimiter
delim := l.next()
var prev rune
// ignore delimiter
l.ignore()
for {
r := l.next()
if r == eof || r == '\n' {
return l.errorf("Unterminated string")
}
if (r == delim) && (prev != '\\') {
break
}
prev = r
}
// remove end delimiter
l.backup()
// emit string
l.emitString(delim)
// skip end delimiter
l.next()
l.ignore()
return lexExpression
}
// lexNumber scans a number: decimal, octal, hex, float, or imaginary. This
// isn't a perfect number scanner - for instance it accepts "." and "0x0.2"
// and "089" - but when it's wrong the input is invalid and the parser (via
// strconv) will notice.
//
// NOTE: borrowed from https://github.com/golang/go/tree/master/src/text/template/parse/lex.go
func lexNumber(l *Lexer) lexFunc {
if !l.scanNumber() {
return l.errorf("bad number syntax: %q", l.input[l.start:l.pos])
}
if sign := l.peek(); sign == '+' || sign == '-' {
// Complex: 1+2i. No spaces, must end in 'i'.
if !l.scanNumber() || l.input[l.pos-1] != 'i' {
return l.errorf("bad number syntax: %q", l.input[l.start:l.pos])
}
l.emit(TokenNumber)
} else {
l.emit(TokenNumber)
}
return lexExpression
}
// scanNumber scans a number
//
// NOTE: borrowed from https://github.com/golang/go/tree/master/src/text/template/parse/lex.go
func (l *Lexer) scanNumber() bool {
// Optional leading sign.
l.accept("+-")
// Is it hex?
digits := "0123456789"
if l.accept("0") && l.accept("xX") {
digits = "0123456789abcdefABCDEF"
}
l.acceptRun(digits)
if l.accept(".") {
l.acceptRun(digits)
}
if l.accept("eE") {
l.accept("+-")
l.acceptRun("0123456789")
}
// Is it imaginary?
l.accept("i")
// Next thing mustn't be alphanumeric.
if isAlphaNumeric(l.peek()) {
l.next()
return false
}
return true
}
// lexIdentifier scans an ID
func lexIdentifier(l *Lexer) lexFunc {
str := l.findRegexp(rID)
if len(str) == 0 {
// this is rotten
panic("Identifier expected")
}
l.pos += len(str)
l.emit(TokenID)
return lexExpression
}
// lexPathLiteral scans an [ID]
func lexPathLiteral(l *Lexer) lexFunc {
for {
r := l.next()
if r == eof || r == '\n' {
return l.errorf("Unterminated path literal")
}
if r == ']' {
break
}
}
l.emit(TokenID)
return lexExpression
}
// isIgnorable returns true if given character is ignorable (ie. whitespace of line feed)
func isIgnorable(r rune) bool {
return r == ' ' || r == '\t' || r == '\n'
}
// isAlphaNumeric reports whether r is an alphabetic, digit, or underscore.
//
// NOTE borrowed from https://github.com/golang/go/tree/master/src/text/template/parse/lex.go
func isAlphaNumeric(r rune) bool {
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
}

541
lexer/lexer_test.go Normal file
View File

@ -0,0 +1,541 @@
package lexer
import (
"fmt"
"testing"
)
type lexTest struct {
name string
input string
tokens []Token
}
// helpers
func tokContent(val string) Token { return Token{TokenContent, val, 0, 1} }
func tokID(val string) Token { return Token{TokenID, val, 0, 1} }
func tokSep(val string) Token { return Token{TokenSep, val, 0, 1} }
func tokString(val string) Token { return Token{TokenString, val, 0, 1} }
func tokNumber(val string) Token { return Token{TokenNumber, val, 0, 1} }
func tokInverse(val string) Token { return Token{TokenInverse, val, 0, 1} }
func tokBool(val string) Token { return Token{TokenBoolean, val, 0, 1} }
func tokError(val string) Token { return Token{TokenError, val, 0, 1} }
func tokComment(val string) Token { return Token{TokenComment, val, 0, 1} }
var tokEOF = Token{TokenEOF, "", 0, 1}
var tokEquals = Token{TokenEquals, "=", 0, 1}
var tokData = Token{TokenData, "@", 0, 1}
var tokOpen = Token{TokenOpen, "{{", 0, 1}
var tokOpenAmp = Token{TokenOpen, "{{&", 0, 1}
var tokOpenPartial = Token{TokenOpenPartial, "{{>", 0, 1}
var tokClose = Token{TokenClose, "}}", 0, 1}
var tokOpenStrip = Token{TokenOpen, "{{~", 0, 1}
var tokCloseStrip = Token{TokenClose, "~}}", 0, 1}
var tokOpenUnescaped = Token{TokenOpenUnescaped, "{{{", 0, 1}
var tokCloseUnescaped = Token{TokenCloseUnescaped, "}}}", 0, 1}
var tokOpenUnescapedStrip = Token{TokenOpenUnescaped, "{{~{", 0, 1}
var tokCloseUnescapedStrip = Token{TokenCloseUnescaped, "}~}}", 0, 1}
var tokOpenBlock = Token{TokenOpenBlock, "{{#", 0, 1}
var tokOpenEndBlock = Token{TokenOpenEndBlock, "{{/", 0, 1}
var tokOpenInverse = Token{TokenOpenInverse, "{{^", 0, 1}
var tokOpenInverseChain = Token{TokenOpenInverseChain, "{{else", 0, 1}
var tokOpenSexpr = Token{TokenOpenSexpr, "(", 0, 1}
var tokCloseSexpr = Token{TokenCloseSexpr, ")", 0, 1}
var tokOpenBlockParams = Token{TokenOpenBlockParams, "as |", 0, 1}
var tokCloseBlockParams = Token{TokenCloseBlockParams, "|", 0, 1}
var tokOpenRawBlock = Token{TokenOpenRawBlock, "{{{{", 0, 1}
var tokCloseRawBlock = Token{TokenCloseRawBlock, "}}}}", 0, 1}
var tokOpenEndRawBlock = Token{TokenOpenEndRawBlock, "{{{{/", 0, 1}
var lexTests = []lexTest{
{"empty", "", []Token{tokEOF}},
{"spaces", " \t\n", []Token{tokContent(" \t\n"), tokEOF}},
{"content", `now is the time`, []Token{tokContent(`now is the time`), tokEOF}},
{
`does not tokenizes identifier starting with true as boolean`,
`{{ foo truebar }}`,
[]Token{tokOpen, tokID("foo"), tokID("truebar"), tokClose, tokEOF},
},
{
`does not tokenizes identifier starting with false as boolean`,
`{{ foo falsebar }}`,
[]Token{tokOpen, tokID("foo"), tokID("falsebar"), tokClose, tokEOF},
},
{
`tokenizes raw block`,
`{{{{foo}}}} {{{{/foo}}}}`,
[]Token{tokOpenRawBlock, tokID("foo"), tokCloseRawBlock, tokContent(" "), tokOpenEndRawBlock, tokID("foo"), tokCloseRawBlock, tokEOF},
},
{
`tokenizes raw block with mustaches in content`,
`{{{{foo}}}}{{bar}}{{{{/foo}}}}`,
[]Token{tokOpenRawBlock, tokID("foo"), tokCloseRawBlock, tokContent("{{bar}}"), tokOpenEndRawBlock, tokID("foo"), tokCloseRawBlock, tokEOF},
},
{
`tokenizes @../foo`,
`{{@../foo}}`,
[]Token{tokOpen, tokData, tokID(".."), tokSep("/"), tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes escaped mustaches`,
"\\{{bar}}",
[]Token{tokContent("{{bar}}"), tokEOF},
},
{
`tokenizes strip mustaches`,
`{{~ foo ~}}`,
[]Token{tokOpenStrip, tokID("foo"), tokCloseStrip, tokEOF},
},
{
`tokenizes unescaped strip mustaches`,
`{{~{ foo }~}}`,
[]Token{tokOpenUnescapedStrip, tokID("foo"), tokCloseUnescapedStrip, tokEOF},
},
//
// Next tests come from:
// https://github.com/wycats/handlebars.js/blob/master/spec/tokenizer.js
//
{
`tokenizes a simple mustache as "OPEN ID CLOSE"`,
`{{foo}}`,
[]Token{tokOpen, tokID("foo"), tokClose, tokEOF},
},
{
`supports unescaping with &`,
`{{&bar}}`,
[]Token{tokOpenAmp, tokID("bar"), tokClose, tokEOF},
},
{
`supports unescaping with {{{`,
`{{{bar}}}`,
[]Token{tokOpenUnescaped, tokID("bar"), tokCloseUnescaped, tokEOF},
},
{
`supports escaping delimiters`,
"{{foo}} \\{{bar}} {{baz}}",
[]Token{tokOpen, tokID("foo"), tokClose, tokContent(" "), tokContent("{{bar}} "), tokOpen, tokID("baz"), tokClose, tokEOF},
},
{
`supports escaping multiple delimiters`,
"{{foo}} \\{{bar}} \\{{baz}}",
[]Token{tokOpen, tokID("foo"), tokClose, tokContent(" "), tokContent("{{bar}} "), tokContent("{{baz}}"), tokEOF},
},
{
`supports escaping a triple stash`,
"{{foo}} \\{{{bar}}} {{baz}}",
[]Token{tokOpen, tokID("foo"), tokClose, tokContent(" "), tokContent("{{{bar}}} "), tokOpen, tokID("baz"), tokClose, tokEOF},
},
{
`supports escaping escape character`,
"{{foo}} \\\\{{bar}} {{baz}}",
[]Token{tokOpen, tokID("foo"), tokClose, tokContent(" \\"), tokOpen, tokID("bar"), tokClose, tokContent(" "), tokOpen, tokID("baz"), tokClose, tokEOF},
},
{
`supports escaping multiple escape characters`,
"{{foo}} \\\\{{bar}} \\\\{{baz}}",
[]Token{tokOpen, tokID("foo"), tokClose, tokContent(" \\"), tokOpen, tokID("bar"), tokClose, tokContent(" \\"), tokOpen, tokID("baz"), tokClose, tokEOF},
},
{
`supports escaped mustaches after escaped escape characters`,
"{{foo}} \\\\{{bar}} \\{{baz}}",
// NOTE: JS implementation returns:
// ['OPEN', 'ID', 'CLOSE', 'CONTENT', 'OPEN', 'ID', 'CLOSE', 'CONTENT', 'CONTENT', 'CONTENT'],
// WTF is the last CONTENT ?
[]Token{tokOpen, tokID("foo"), tokClose, tokContent(" \\"), tokOpen, tokID("bar"), tokClose, tokContent(" "), tokContent("{{baz}}"), tokEOF},
},
{
`supports escaped escape characters after escaped mustaches`,
"{{foo}} \\{{bar}} \\\\{{baz}}",
// NOTE: JS implementation returns:
// []Token{tokOpen, tokID("foo"), tokClose, tokContent(" "), tokContent("{{bar}} "), tokContent("\\"), tokOpen, tokID("baz"), tokClose, tokEOF},
[]Token{tokOpen, tokID("foo"), tokClose, tokContent(" "), tokContent("{{bar}} \\"), tokOpen, tokID("baz"), tokClose, tokEOF},
},
{
`supports escaped escape character on a triple stash`,
"{{foo}} \\\\{{{bar}}} {{baz}}",
[]Token{tokOpen, tokID("foo"), tokClose, tokContent(" \\"), tokOpenUnescaped, tokID("bar"), tokCloseUnescaped, tokContent(" "), tokOpen, tokID("baz"), tokClose, tokEOF},
},
{
`tokenizes a simple path`,
`{{foo/bar}}`,
[]Token{tokOpen, tokID("foo"), tokSep("/"), tokID("bar"), tokClose, tokEOF},
},
{
`allows dot notation (1)`,
`{{foo.bar}}`,
[]Token{tokOpen, tokID("foo"), tokSep("."), tokID("bar"), tokClose, tokEOF},
},
{
`allows dot notation (2)`,
`{{foo.bar.baz}}`,
[]Token{tokOpen, tokID("foo"), tokSep("."), tokID("bar"), tokSep("."), tokID("baz"), tokClose, tokEOF},
},
{
`allows path literals with []`,
`{{foo.[bar]}}`,
[]Token{tokOpen, tokID("foo"), tokSep("."), tokID("[bar]"), tokClose, tokEOF},
},
{
`allows multiple path literals on a line with []`,
`{{foo.[bar]}}{{foo.[baz]}}`,
[]Token{tokOpen, tokID("foo"), tokSep("."), tokID("[bar]"), tokClose, tokOpen, tokID("foo"), tokSep("."), tokID("[baz]"), tokClose, tokEOF},
},
{
`tokenizes {{.}} as OPEN ID CLOSE`,
`{{.}}`,
[]Token{tokOpen, tokID("."), tokClose, tokEOF},
},
{
`tokenizes a path as "OPEN (ID SEP)* ID CLOSE"`,
`{{../foo/bar}}`,
[]Token{tokOpen, tokID(".."), tokSep("/"), tokID("foo"), tokSep("/"), tokID("bar"), tokClose, tokEOF},
},
{
`tokenizes a path with .. as a parent path`,
`{{../foo.bar}}`,
[]Token{tokOpen, tokID(".."), tokSep("/"), tokID("foo"), tokSep("."), tokID("bar"), tokClose, tokEOF},
},
{
`tokenizes a path with this/foo as OPEN ID SEP ID CLOSE`,
`{{this/foo}}`,
[]Token{tokOpen, tokID("this"), tokSep("/"), tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes a simple mustache with spaces as "OPEN ID CLOSE"`,
`{{ foo }}`,
[]Token{tokOpen, tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes a simple mustache with line breaks as "OPEN ID ID CLOSE"`,
"{{ foo \n bar }}",
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokClose, tokEOF},
},
{
`tokenizes raw content as "CONTENT"`,
`foo {{ bar }} baz`,
[]Token{tokContent("foo "), tokOpen, tokID("bar"), tokClose, tokContent(" baz"), tokEOF},
},
{
`tokenizes a partial as "OPEN_PARTIAL ID CLOSE"`,
`{{> foo}}`,
[]Token{tokOpenPartial, tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes a partial with context as "OPEN_PARTIAL ID ID CLOSE"`,
`{{> foo bar }}`,
[]Token{tokOpenPartial, tokID("foo"), tokID("bar"), tokClose, tokEOF},
},
{
`tokenizes a partial without spaces as "OPEN_PARTIAL ID CLOSE"`,
`{{>foo}}`,
[]Token{tokOpenPartial, tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes a partial space at the }); as "OPEN_PARTIAL ID CLOSE"`,
`{{>foo }}`,
[]Token{tokOpenPartial, tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes a partial space at the }); as "OPEN_PARTIAL ID CLOSE"`,
`{{>foo/bar.baz }}`,
[]Token{tokOpenPartial, tokID("foo"), tokSep("/"), tokID("bar"), tokSep("."), tokID("baz"), tokClose, tokEOF},
},
{
`tokenizes a comment as "COMMENT"`,
`foo {{! this is a comment }} bar {{ baz }}`,
[]Token{tokContent("foo "), tokComment("{{! this is a comment }}"), tokContent(" bar "), tokOpen, tokID("baz"), tokClose, tokEOF},
},
{
`tokenizes a block comment as "COMMENT"`,
`foo {{!-- this is a {{comment}} --}} bar {{ baz }}`,
[]Token{tokContent("foo "), tokComment("{{!-- this is a {{comment}} --}}"), tokContent(" bar "), tokOpen, tokID("baz"), tokClose, tokEOF},
},
{
`tokenizes a block comment with whitespace as "COMMENT"`,
"foo {{!-- this is a\n{{comment}}\n--}} bar {{ baz }}",
[]Token{tokContent("foo "), tokComment("{{!-- this is a\n{{comment}}\n--}}"), tokContent(" bar "), tokOpen, tokID("baz"), tokClose, tokEOF},
},
{
`tokenizes open and closing blocks as OPEN_BLOCK, ID, CLOSE ..., OPEN_ENDBLOCK ID CLOSE`,
`{{#foo}}content{{/foo}}`,
[]Token{tokOpenBlock, tokID("foo"), tokClose, tokContent("content"), tokOpenEndBlock, tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes inverse sections as "INVERSE"`,
`{{^}}`,
[]Token{tokInverse("{{^}}"), tokEOF},
},
{
`tokenizes inverse sections as "INVERSE" with alternate format`,
`{{else}}`,
[]Token{tokInverse("{{else}}"), tokEOF},
},
{
`tokenizes inverse sections as "INVERSE" with spaces`,
`{{ else }}`,
[]Token{tokInverse("{{ else }}"), tokEOF},
},
{
`tokenizes inverse sections with ID as "OPEN_INVERSE ID CLOSE"`,
`{{^foo}}`,
[]Token{tokOpenInverse, tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes inverse sections with ID and spaces as "OPEN_INVERSE ID CLOSE"`,
`{{^ foo }}`,
[]Token{tokOpenInverse, tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes mustaches with params as "OPEN ID ID ID CLOSE"`,
`{{ foo bar baz }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokID("baz"), tokClose, tokEOF},
},
{
`tokenizes mustaches with String params as "OPEN ID ID STRING CLOSE"`,
`{{ foo bar "baz" }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokString("baz"), tokClose, tokEOF},
},
{
`tokenizes mustaches with String params using single quotes as "OPEN ID ID STRING CLOSE"`,
`{{ foo bar 'baz' }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokString("baz"), tokClose, tokEOF},
},
{
`tokenizes String params with spaces inside as "STRING"`,
`{{ foo bar "baz bat" }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokString("baz bat"), tokClose, tokEOF},
},
{
`tokenizes String params with escapes quotes as STRING`,
`{{ foo "bar\"baz" }}`,
[]Token{tokOpen, tokID("foo"), tokString(`bar"baz`), tokClose, tokEOF},
},
{
`tokenizes String params using single quotes with escapes quotes as STRING`,
`{{ foo 'bar\'baz' }}`,
[]Token{tokOpen, tokID("foo"), tokString(`bar'baz`), tokClose, tokEOF},
},
{
`tokenizes numbers`,
`{{ foo 1 }}`,
[]Token{tokOpen, tokID("foo"), tokNumber("1"), tokClose, tokEOF},
},
{
`tokenizes floats`,
`{{ foo 1.1 }}`,
[]Token{tokOpen, tokID("foo"), tokNumber("1.1"), tokClose, tokEOF},
},
{
`tokenizes negative numbers`,
`{{ foo -1 }}`,
[]Token{tokOpen, tokID("foo"), tokNumber("-1"), tokClose, tokEOF},
},
{
`tokenizes negative floats`,
`{{ foo -1.1 }}`,
[]Token{tokOpen, tokID("foo"), tokNumber("-1.1"), tokClose, tokEOF},
},
{
`tokenizes boolean true`,
`{{ foo true }}`,
[]Token{tokOpen, tokID("foo"), tokBool("true"), tokClose, tokEOF},
},
{
`tokenizes boolean false`,
`{{ foo false }}`,
[]Token{tokOpen, tokID("foo"), tokBool("false"), tokClose, tokEOF},
},
// SKIP: 'tokenizes undefined and null'
{
`tokenizes hash arguments (1)`,
`{{ foo bar=baz }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokEquals, tokID("baz"), tokClose, tokEOF},
},
{
`tokenizes hash arguments (2)`,
`{{ foo bar baz=bat }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokID("baz"), tokEquals, tokID("bat"), tokClose, tokEOF},
},
{
`tokenizes hash arguments (3)`,
`{{ foo bar baz=1 }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokID("baz"), tokEquals, tokNumber("1"), tokClose, tokEOF},
},
{
`tokenizes hash arguments (4)`,
`{{ foo bar baz=true }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokID("baz"), tokEquals, tokBool("true"), tokClose, tokEOF},
},
{
`tokenizes hash arguments (5)`,
`{{ foo bar baz=false }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokID("baz"), tokEquals, tokBool("false"), tokClose, tokEOF},
},
{
`tokenizes hash arguments (6)`,
"{{ foo bar\n baz=bat }}",
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokID("baz"), tokEquals, tokID("bat"), tokClose, tokEOF},
},
{
`tokenizes hash arguments (7)`,
`{{ foo bar baz="bat" }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokID("baz"), tokEquals, tokString("bat"), tokClose, tokEOF},
},
{
`tokenizes hash arguments (8)`,
`{{ foo bar baz="bat" bam=wot }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokID("baz"), tokEquals, tokString("bat"), tokID("bam"), tokEquals, tokID("wot"), tokClose, tokEOF},
},
{
`tokenizes hash arguments (9)`,
`{{foo omg bar=baz bat="bam"}}`,
[]Token{tokOpen, tokID("foo"), tokID("omg"), tokID("bar"), tokEquals, tokID("baz"), tokID("bat"), tokEquals, tokString("bam"), tokClose, tokEOF},
},
{
`tokenizes special @ identifiers (1)`,
`{{ @foo }}`,
[]Token{tokOpen, tokData, tokID("foo"), tokClose, tokEOF},
},
{
`tokenizes special @ identifiers (2)`,
`{{ foo @bar }}`,
[]Token{tokOpen, tokID("foo"), tokData, tokID("bar"), tokClose, tokEOF},
},
{
`tokenizes special @ identifiers (3)`,
`{{ foo bar=@baz }}`,
[]Token{tokOpen, tokID("foo"), tokID("bar"), tokEquals, tokData, tokID("baz"), tokClose, tokEOF},
},
{
`does not time out in a mustache with a single } followed by EOF`,
`{{foo}`,
[]Token{tokOpen, tokID("foo"), tokError("Unexpected character in expression: '}'")},
},
{
`does not time out in a mustache when invalid ID characters are used`,
`{{foo & }}`,
[]Token{tokOpen, tokID("foo"), tokError("Unexpected character in expression: '&'")},
},
{
`tokenizes subexpressions (1)`,
`{{foo (bar)}}`,
[]Token{tokOpen, tokID("foo"), tokOpenSexpr, tokID("bar"), tokCloseSexpr, tokClose, tokEOF},
},
{
`tokenizes subexpressions (2)`,
`{{foo (a-x b-y)}}`,
[]Token{tokOpen, tokID("foo"), tokOpenSexpr, tokID("a-x"), tokID("b-y"), tokCloseSexpr, tokClose, tokEOF},
},
{
`tokenizes nested subexpressions`,
`{{foo (bar (lol rofl)) (baz)}}`,
[]Token{tokOpen, tokID("foo"), tokOpenSexpr, tokID("bar"), tokOpenSexpr, tokID("lol"), tokID("rofl"), tokCloseSexpr, tokCloseSexpr, tokOpenSexpr, tokID("baz"), tokCloseSexpr, tokClose, tokEOF},
},
{
`tokenizes nested subexpressions: literals`,
`{{foo (bar (lol true) false) (baz 1) (blah 'b') (blorg "c")}}`,
[]Token{tokOpen, tokID("foo"), tokOpenSexpr, tokID("bar"), tokOpenSexpr, tokID("lol"), tokBool("true"), tokCloseSexpr, tokBool("false"), tokCloseSexpr, tokOpenSexpr, tokID("baz"), tokNumber("1"), tokCloseSexpr, tokOpenSexpr, tokID("blah"), tokString("b"), tokCloseSexpr, tokOpenSexpr, tokID("blorg"), tokString("c"), tokCloseSexpr, tokClose, tokEOF},
},
{
`tokenizes block params (1)`,
`{{#foo as |bar|}}`,
[]Token{tokOpenBlock, tokID("foo"), tokOpenBlockParams, tokID("bar"), tokCloseBlockParams, tokClose, tokEOF},
},
{
`tokenizes block params (2)`,
`{{#foo as |bar baz|}}`,
[]Token{tokOpenBlock, tokID("foo"), tokOpenBlockParams, tokID("bar"), tokID("baz"), tokCloseBlockParams, tokClose, tokEOF},
},
{
`tokenizes block params (3)`,
`{{#foo as | bar baz |}}`,
[]Token{tokOpenBlock, tokID("foo"), tokOpenBlockParams, tokID("bar"), tokID("baz"), tokCloseBlockParams, tokClose, tokEOF},
},
{
`tokenizes block params (4)`,
`{{#foo as as | bar baz |}}`,
[]Token{tokOpenBlock, tokID("foo"), tokID("as"), tokOpenBlockParams, tokID("bar"), tokID("baz"), tokCloseBlockParams, tokClose, tokEOF},
},
{
`tokenizes block params (5)`,
`{{else foo as |bar baz|}}`,
[]Token{tokOpenInverseChain, tokID("foo"), tokOpenBlockParams, tokID("bar"), tokID("baz"), tokCloseBlockParams, tokClose, tokEOF},
},
}
func collect(t *lexTest) []Token {
var result []Token
l := scanWithName(t.input, t.name)
for {
token := l.NextToken()
result = append(result, token)
if token.Kind == TokenEOF || token.Kind == TokenError {
break
}
}
return result
}
func equal(i1, i2 []Token, checkPos bool) bool {
if len(i1) != len(i2) {
return false
}
for k := range i1 {
if i1[k].Kind != i2[k].Kind {
return false
}
if checkPos && i1[k].Pos != i2[k].Pos {
return false
}
if i1[k].Val != i2[k].Val {
return false
}
}
return true
}
func TestLexer(t *testing.T) {
t.Parallel()
for _, test := range lexTests {
tokens := collect(&test)
if !equal(tokens, test.tokens, false) {
t.Errorf("Test '%s' failed\ninput:\n\t'%s'\nexpected\n\t%v\ngot\n\t%+v\n", test.name, test.input, test.tokens, tokens)
}
}
}
// @todo Test errors:
// `{{{{raw foo`
// package example
func Example() {
source := "You know {{nothing}} John Snow"
output := ""
lex := Scan(source)
for {
// consume next token
token := lex.NextToken()
output += fmt.Sprintf(" %s", token)
// stops when all tokens have been consumed, or on error
if token.Kind == TokenEOF || token.Kind == TokenError {
break
}
}
fmt.Print(output)
// Output: Content{"You know "} Open{"{{"} ID{"nothing"} Close{"}}"} Content{" John Snow"} EOF
}

183
lexer/token.go Normal file
View File

@ -0,0 +1,183 @@
package lexer
import "fmt"
const (
// TokenError represents an error
TokenError TokenKind = iota
// TokenEOF represents an End Of File
TokenEOF
//
// Mustache delimiters
//
// TokenOpen is the OPEN token
TokenOpen
// TokenClose is the CLOSE token
TokenClose
// TokenOpenRawBlock is the OPEN_RAW_BLOCK token
TokenOpenRawBlock
// TokenCloseRawBlock is the CLOSE_RAW_BLOCK token
TokenCloseRawBlock
// TokenOpenEndRawBlock is the END_RAW_BLOCK token
TokenOpenEndRawBlock
// TokenOpenUnescaped is the OPEN_UNESCAPED token
TokenOpenUnescaped
// TokenCloseUnescaped is the CLOSE_UNESCAPED token
TokenCloseUnescaped
// TokenOpenBlock is the OPEN_BLOCK token
TokenOpenBlock
// TokenOpenEndBlock is the OPEN_ENDBLOCK token
TokenOpenEndBlock
// TokenInverse is the INVERSE token
TokenInverse
// TokenOpenInverse is the OPEN_INVERSE token
TokenOpenInverse
// TokenOpenInverseChain is the OPEN_INVERSE_CHAIN token
TokenOpenInverseChain
// TokenOpenPartial is the OPEN_PARTIAL token
TokenOpenPartial
// TokenComment is the COMMENT token
TokenComment
//
// Inside mustaches
//
// TokenOpenSexpr is the OPEN_SEXPR token
TokenOpenSexpr
// TokenCloseSexpr is the CLOSE_SEXPR token
TokenCloseSexpr
// TokenEquals is the EQUALS token
TokenEquals
// TokenData is the DATA token
TokenData
// TokenSep is the SEP token
TokenSep
// TokenOpenBlockParams is the OPEN_BLOCK_PARAMS token
TokenOpenBlockParams
// TokenCloseBlockParams is the CLOSE_BLOCK_PARAMS token
TokenCloseBlockParams
//
// Tokens with content
//
// TokenContent is the CONTENT token
TokenContent
// TokenID is the ID token
TokenID
// TokenString is the STRING token
TokenString
// TokenNumber is the NUMBER token
TokenNumber
// TokenBoolean is the BOOLEAN token
TokenBoolean
)
const (
// Option to generate token position in its string representation
dumpTokenPos = false
// Option to generate values for all token kinds for their string representations
dumpAllTokensVal = true
)
// TokenKind represents a Token type.
type TokenKind int
// Token represents a scanned token.
type Token struct {
Kind TokenKind // Token kind
Val string // Token value
Pos int // Byte position in input string
Line int // Line number in input string
}
// tokenName permits to display token name given token type
var tokenName = map[TokenKind]string{
TokenError: "Error",
TokenEOF: "EOF",
TokenContent: "Content",
TokenComment: "Comment",
TokenOpen: "Open",
TokenClose: "Close",
TokenOpenUnescaped: "OpenUnescaped",
TokenCloseUnescaped: "CloseUnescaped",
TokenOpenBlock: "OpenBlock",
TokenOpenEndBlock: "OpenEndBlock",
TokenOpenRawBlock: "OpenRawBlock",
TokenCloseRawBlock: "CloseRawBlock",
TokenOpenEndRawBlock: "OpenEndRawBlock",
TokenOpenBlockParams: "OpenBlockParams",
TokenCloseBlockParams: "CloseBlockParams",
TokenInverse: "Inverse",
TokenOpenInverse: "OpenInverse",
TokenOpenInverseChain: "OpenInverseChain",
TokenOpenPartial: "OpenPartial",
TokenOpenSexpr: "OpenSexpr",
TokenCloseSexpr: "CloseSexpr",
TokenID: "ID",
TokenEquals: "Equals",
TokenString: "String",
TokenNumber: "Number",
TokenBoolean: "Boolean",
TokenData: "Data",
TokenSep: "Sep",
}
// String returns the token kind string representation for debugging.
func (k TokenKind) String() string {
s := tokenName[k]
if s == "" {
return fmt.Sprintf("Token-%d", int(k))
}
return s
}
// String returns the token string representation for debugging.
func (t Token) String() string {
result := ""
if dumpTokenPos {
result += fmt.Sprintf("%d:", t.Pos)
}
result += fmt.Sprintf("%s", t.Kind)
if (dumpAllTokensVal || (t.Kind >= TokenContent)) && len(t.Val) > 0 {
if len(t.Val) > 100 {
result += fmt.Sprintf("{%.20q...}", t.Val)
} else {
result += fmt.Sprintf("{%q}", t.Val)
}
}
return result
}

View File

@ -0,0 +1,106 @@
{
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
"overview": "Comment tags represent content that should never appear in the resulting\noutput.\n\nThe tag's content may contain any substring (including newlines) EXCEPT the\nclosing delimiter.\n\nComment tags SHOULD be treated as standalone when appropriate.\n",
"tests": [
{
"name": "Inline",
"desc": "Comment blocks should be removed from the template.",
"data": {
},
"template": "12345{{! Comment Block! }}67890",
"expected": "1234567890"
},
{
"name": "Multiline",
"desc": "Multiline comments should be permitted.",
"data": {
},
"template": "12345{{!\n This is a\n multi-line comment...\n}}67890\n",
"expected": "1234567890\n"
},
{
"name": "Standalone",
"desc": "All standalone comment lines should be removed.",
"data": {
},
"template": "Begin.\n{{! Comment Block! }}\nEnd.\n",
"expected": "Begin.\nEnd.\n"
},
{
"name": "Indented Standalone",
"desc": "All standalone comment lines should be removed.",
"data": {
},
"template": "Begin.\n {{! Indented Comment Block! }}\nEnd.\n",
"expected": "Begin.\nEnd.\n"
},
{
"name": "Standalone Line Endings",
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
"data": {
},
"template": "|\r\n{{! Standalone Comment }}\r\n|",
"expected": "|\r\n|"
},
{
"name": "Standalone Without Previous Line",
"desc": "Standalone tags should not require a newline to precede them.",
"data": {
},
"template": " {{! I'm Still Standalone }}\n!",
"expected": "!"
},
{
"name": "Standalone Without Newline",
"desc": "Standalone tags should not require a newline to follow them.",
"data": {
},
"template": "!\n {{! I'm Still Standalone }}",
"expected": "!\n"
},
{
"name": "Multiline Standalone",
"desc": "All standalone comment lines should be removed.",
"data": {
},
"template": "Begin.\n{{!\nSomething's going on here...\n}}\nEnd.\n",
"expected": "Begin.\nEnd.\n"
},
{
"name": "Indented Multiline Standalone",
"desc": "All standalone comment lines should be removed.",
"data": {
},
"template": "Begin.\n {{!\n Something's going on here...\n }}\nEnd.\n",
"expected": "Begin.\nEnd.\n"
},
{
"name": "Indented Inline",
"desc": "Inline comments should not strip whitespace",
"data": {
},
"template": " 12 {{! 34 }}\n",
"expected": " 12 \n"
},
{
"name": "Surrounding Whitespace",
"desc": "Comment removal should preserve surrounding whitespace.",
"data": {
},
"template": "12345 {{! Comment Block! }} 67890",
"expected": "12345 67890"
},
{
"name": "Variable Name Collision",
"desc": "Comments must never render, even if variable with same name exists.",
"data": {
"! comment": 1,
"! comment ": 2,
"!comment": 3,
"comment": 4
},
"template": "comments never show: >{{! comment }}<",
"expected": "comments never show: ><"
}
]
}

109
mustache/specs/comments.yml Normal file
View File

@ -0,0 +1,109 @@
overview: |
Comment tags represent content that should never appear in the resulting
output.
The tag's content may contain any substring (including newlines) EXCEPT the
closing delimiter.
Comment tags SHOULD be treated as standalone when appropriate.
tests:
- name: Inline
desc: Comment blocks should be removed from the template.
data: { }
template: '12345{{! Comment Block! }}67890'
expected: '1234567890'
- name: Multiline
desc: Multiline comments should be permitted.
data: { }
template: |
12345{{!
This is a
multi-line comment...
}}67890
expected: |
1234567890
- name: Standalone
desc: All standalone comment lines should be removed.
data: { }
template: |
Begin.
{{! Comment Block! }}
End.
expected: |
Begin.
End.
- name: Indented Standalone
desc: All standalone comment lines should be removed.
data: { }
template: |
Begin.
{{! Indented Comment Block! }}
End.
expected: |
Begin.
End.
- name: Standalone Line Endings
desc: '"\r\n" should be considered a newline for standalone tags.'
data: { }
template: "|\r\n{{! Standalone Comment }}\r\n|"
expected: "|\r\n|"
- name: Standalone Without Previous Line
desc: Standalone tags should not require a newline to precede them.
data: { }
template: " {{! I'm Still Standalone }}\n!"
expected: "!"
- name: Standalone Without Newline
desc: Standalone tags should not require a newline to follow them.
data: { }
template: "!\n {{! I'm Still Standalone }}"
expected: "!\n"
- name: Multiline Standalone
desc: All standalone comment lines should be removed.
data: { }
template: |
Begin.
{{!
Something's going on here...
}}
End.
expected: |
Begin.
End.
- name: Indented Multiline Standalone
desc: All standalone comment lines should be removed.
data: { }
template: |
Begin.
{{!
Something's going on here...
}}
End.
expected: |
Begin.
End.
- name: Indented Inline
desc: Inline comments should not strip whitespace
data: { }
template: " 12 {{! 34 }}\n"
expected: " 12 \n"
- name: Surrounding Whitespace
desc: Comment removal should preserve surrounding whitespace.
data: { }
template: '12345 {{! Comment Block! }} 67890'
expected: '12345 67890'
- name: Variable Name Collision
desc: Comments must never render, even if variable with same name exists.
data: { '! comment': 1, '! comment ': 2, '!comment': 3, 'comment': 4}
template: 'comments never show: >{{! comment }}<'
expected: 'comments never show: ><'

View File

@ -0,0 +1,132 @@
{
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
"overview": "Set Delimiter tags are used to change the tag delimiters for all content\nfollowing the tag in the current compilation unit.\n\nThe tag's content MUST be any two non-whitespace sequences (separated by\nwhitespace) EXCEPT an equals sign ('=') followed by the current closing\ndelimiter.\n\nSet Delimiter tags SHOULD be treated as standalone when appropriate.\n",
"tests": [
{
"name": "Pair Behavior",
"desc": "The equals sign (used on both sides) should permit delimiter changes.",
"data": {
"text": "Hey!"
},
"template": "{{=<% %>=}}(<%text%>)",
"expected": "(Hey!)"
},
{
"name": "Special Characters",
"desc": "Characters with special meaning regexen should be valid delimiters.",
"data": {
"text": "It worked!"
},
"template": "({{=[ ]=}}[text])",
"expected": "(It worked!)"
},
{
"name": "Sections",
"desc": "Delimiters set outside sections should persist.",
"data": {
"section": true,
"data": "I got interpolated."
},
"template": "[\n{{#section}}\n {{data}}\n |data|\n{{/section}}\n\n{{= | | =}}\n|#section|\n {{data}}\n |data|\n|/section|\n]\n",
"expected": "[\n I got interpolated.\n |data|\n\n {{data}}\n I got interpolated.\n]\n"
},
{
"name": "Inverted Sections",
"desc": "Delimiters set outside inverted sections should persist.",
"data": {
"section": false,
"data": "I got interpolated."
},
"template": "[\n{{^section}}\n {{data}}\n |data|\n{{/section}}\n\n{{= | | =}}\n|^section|\n {{data}}\n |data|\n|/section|\n]\n",
"expected": "[\n I got interpolated.\n |data|\n\n {{data}}\n I got interpolated.\n]\n"
},
{
"name": "Partial Inheritence",
"desc": "Delimiters set in a parent template should not affect a partial.",
"data": {
"value": "yes"
},
"partials": {
"include": ".{{value}}."
},
"template": "[ {{>include}} ]\n{{= | | =}}\n[ |>include| ]\n",
"expected": "[ .yes. ]\n[ .yes. ]\n"
},
{
"name": "Post-Partial Behavior",
"desc": "Delimiters set in a partial should not affect the parent template.",
"data": {
"value": "yes"
},
"partials": {
"include": ".{{value}}. {{= | | =}} .|value|."
},
"template": "[ {{>include}} ]\n[ .{{value}}. .|value|. ]\n",
"expected": "[ .yes. .yes. ]\n[ .yes. .|value|. ]\n"
},
{
"name": "Surrounding Whitespace",
"desc": "Surrounding whitespace should be left untouched.",
"data": {
},
"template": "| {{=@ @=}} |",
"expected": "| |"
},
{
"name": "Outlying Whitespace (Inline)",
"desc": "Whitespace should be left untouched.",
"data": {
},
"template": " | {{=@ @=}}\n",
"expected": " | \n"
},
{
"name": "Standalone Tag",
"desc": "Standalone lines should be removed from the template.",
"data": {
},
"template": "Begin.\n{{=@ @=}}\nEnd.\n",
"expected": "Begin.\nEnd.\n"
},
{
"name": "Indented Standalone Tag",
"desc": "Indented standalone lines should be removed from the template.",
"data": {
},
"template": "Begin.\n {{=@ @=}}\nEnd.\n",
"expected": "Begin.\nEnd.\n"
},
{
"name": "Standalone Line Endings",
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
"data": {
},
"template": "|\r\n{{= @ @ =}}\r\n|",
"expected": "|\r\n|"
},
{
"name": "Standalone Without Previous Line",
"desc": "Standalone tags should not require a newline to precede them.",
"data": {
},
"template": " {{=@ @=}}\n=",
"expected": "="
},
{
"name": "Standalone Without Newline",
"desc": "Standalone tags should not require a newline to follow them.",
"data": {
},
"template": "=\n {{=@ @=}}",
"expected": "=\n"
},
{
"name": "Pair with Padding",
"desc": "Superfluous in-tag whitespace should be ignored.",
"data": {
},
"template": "|{{= @ @ =}}|",
"expected": "||"
}
]
}

View File

@ -0,0 +1,158 @@
overview: |
Set Delimiter tags are used to change the tag delimiters for all content
following the tag in the current compilation unit.
The tag's content MUST be any two non-whitespace sequences (separated by
whitespace) EXCEPT an equals sign ('=') followed by the current closing
delimiter.
Set Delimiter tags SHOULD be treated as standalone when appropriate.
tests:
- name: Pair Behavior
desc: The equals sign (used on both sides) should permit delimiter changes.
data: { text: 'Hey!' }
template: '{{=<% %>=}}(<%text%>)'
expected: '(Hey!)'
- name: Special Characters
desc: Characters with special meaning regexen should be valid delimiters.
data: { text: 'It worked!' }
template: '({{=[ ]=}}[text])'
expected: '(It worked!)'
- name: Sections
desc: Delimiters set outside sections should persist.
data: { section: true, data: 'I got interpolated.' }
template: |
[
{{#section}}
{{data}}
|data|
{{/section}}
{{= | | =}}
|#section|
{{data}}
|data|
|/section|
]
expected: |
[
I got interpolated.
|data|
{{data}}
I got interpolated.
]
- name: Inverted Sections
desc: Delimiters set outside inverted sections should persist.
data: { section: false, data: 'I got interpolated.' }
template: |
[
{{^section}}
{{data}}
|data|
{{/section}}
{{= | | =}}
|^section|
{{data}}
|data|
|/section|
]
expected: |
[
I got interpolated.
|data|
{{data}}
I got interpolated.
]
- name: Partial Inheritence
desc: Delimiters set in a parent template should not affect a partial.
data: { value: 'yes' }
partials:
include: '.{{value}}.'
template: |
[ {{>include}} ]
{{= | | =}}
[ |>include| ]
expected: |
[ .yes. ]
[ .yes. ]
- name: Post-Partial Behavior
desc: Delimiters set in a partial should not affect the parent template.
data: { value: 'yes' }
partials:
include: '.{{value}}. {{= | | =}} .|value|.'
template: |
[ {{>include}} ]
[ .{{value}}. .|value|. ]
expected: |
[ .yes. .yes. ]
[ .yes. .|value|. ]
# Whitespace Sensitivity
- name: Surrounding Whitespace
desc: Surrounding whitespace should be left untouched.
data: { }
template: '| {{=@ @=}} |'
expected: '| |'
- name: Outlying Whitespace (Inline)
desc: Whitespace should be left untouched.
data: { }
template: " | {{=@ @=}}\n"
expected: " | \n"
- name: Standalone Tag
desc: Standalone lines should be removed from the template.
data: { }
template: |
Begin.
{{=@ @=}}
End.
expected: |
Begin.
End.
- name: Indented Standalone Tag
desc: Indented standalone lines should be removed from the template.
data: { }
template: |
Begin.
{{=@ @=}}
End.
expected: |
Begin.
End.
- name: Standalone Line Endings
desc: '"\r\n" should be considered a newline for standalone tags.'
data: { }
template: "|\r\n{{= @ @ =}}\r\n|"
expected: "|\r\n|"
- name: Standalone Without Previous Line
desc: Standalone tags should not require a newline to precede them.
data: { }
template: " {{=@ @=}}\n="
expected: "="
- name: Standalone Without Newline
desc: Standalone tags should not require a newline to follow them.
data: { }
template: "=\n {{=@ @=}}"
expected: "=\n"
# Whitespace Insensitivity
- name: Pair with Padding
desc: Superfluous in-tag whitespace should be ignored.
data: { }
template: '|{{= @ @ =}}|'
expected: '||'

View File

@ -0,0 +1,391 @@
{
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
"overview": "Interpolation tags are used to integrate dynamic content into the template.\n\nThe tag's content MUST be a non-whitespace character sequence NOT containing\nthe current closing delimiter.\n\nThis tag's content names the data to replace the tag. A single period (`.`)\nindicates that the item currently sitting atop the context stack should be\nused; otherwise, name resolution is as follows:\n 1) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 2) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 3) If the context is a hash, the data is the value associated with the\n name.\n 4) If the context is an object, the data is the value returned by the\n method with the given name.\n 5) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\nData should be coerced into a string (and escaped, if appropriate) before\ninterpolation.\n\nThe Interpolation tags MUST NOT be treated as standalone.\n",
"tests": [
{
"name": "No Interpolation",
"desc": "Mustache-free templates should render as-is.",
"data": {
},
"template": "Hello from {Mustache}!\n",
"expected": "Hello from {Mustache}!\n"
},
{
"name": "Basic Interpolation",
"desc": "Unadorned tags should interpolate content into the template.",
"data": {
"subject": "world"
},
"template": "Hello, {{subject}}!\n",
"expected": "Hello, world!\n"
},
{
"name": "HTML Escaping",
"desc": "Basic interpolation should be HTML escaped.",
"data": {
"forbidden": "& \" < >"
},
"template": "These characters should be HTML escaped: {{forbidden}}\n",
"expected": "These characters should be HTML escaped: &amp; &quot; &lt; &gt;\n"
},
{
"name": "Triple Mustache",
"desc": "Triple mustaches should interpolate without HTML escaping.",
"data": {
"forbidden": "& \" < >"
},
"template": "These characters should not be HTML escaped: {{{forbidden}}}\n",
"expected": "These characters should not be HTML escaped: & \" < >\n"
},
{
"name": "Ampersand",
"desc": "Ampersand should interpolate without HTML escaping.",
"data": {
"forbidden": "& \" < >"
},
"template": "These characters should not be HTML escaped: {{&forbidden}}\n",
"expected": "These characters should not be HTML escaped: & \" < >\n"
},
{
"name": "Basic Integer Interpolation",
"desc": "Integers should interpolate seamlessly.",
"data": {
"mph": 85
},
"template": "\"{{mph}} miles an hour!\"",
"expected": "\"85 miles an hour!\""
},
{
"name": "Triple Mustache Integer Interpolation",
"desc": "Integers should interpolate seamlessly.",
"data": {
"mph": 85
},
"template": "\"{{{mph}}} miles an hour!\"",
"expected": "\"85 miles an hour!\""
},
{
"name": "Ampersand Integer Interpolation",
"desc": "Integers should interpolate seamlessly.",
"data": {
"mph": 85
},
"template": "\"{{&mph}} miles an hour!\"",
"expected": "\"85 miles an hour!\""
},
{
"name": "Basic Decimal Interpolation",
"desc": "Decimals should interpolate seamlessly with proper significance.",
"data": {
"power": 1.21
},
"template": "\"{{power}} jiggawatts!\"",
"expected": "\"1.21 jiggawatts!\""
},
{
"name": "Triple Mustache Decimal Interpolation",
"desc": "Decimals should interpolate seamlessly with proper significance.",
"data": {
"power": 1.21
},
"template": "\"{{{power}}} jiggawatts!\"",
"expected": "\"1.21 jiggawatts!\""
},
{
"name": "Ampersand Decimal Interpolation",
"desc": "Decimals should interpolate seamlessly with proper significance.",
"data": {
"power": 1.21
},
"template": "\"{{&power}} jiggawatts!\"",
"expected": "\"1.21 jiggawatts!\""
},
{
"name": "Basic Null Interpolation",
"desc": "Nulls should interpolate as the empty string.",
"data": {
"cannot": null
},
"template": "I ({{cannot}}) be seen!",
"expected": "I () be seen!"
},
{
"name": "Triple Mustache Null Interpolation",
"desc": "Nulls should interpolate as the empty string.",
"data": {
"cannot": null
},
"template": "I ({{{cannot}}}) be seen!",
"expected": "I () be seen!"
},
{
"name": "Ampersand Null Interpolation",
"desc": "Nulls should interpolate as the empty string.",
"data": {
"cannot": null
},
"template": "I ({{&cannot}}) be seen!",
"expected": "I () be seen!"
},
{
"name": "Basic Context Miss Interpolation",
"desc": "Failed context lookups should default to empty strings.",
"data": {
},
"template": "I ({{cannot}}) be seen!",
"expected": "I () be seen!"
},
{
"name": "Triple Mustache Context Miss Interpolation",
"desc": "Failed context lookups should default to empty strings.",
"data": {
},
"template": "I ({{{cannot}}}) be seen!",
"expected": "I () be seen!"
},
{
"name": "Ampersand Context Miss Interpolation",
"desc": "Failed context lookups should default to empty strings.",
"data": {
},
"template": "I ({{&cannot}}) be seen!",
"expected": "I () be seen!"
},
{
"name": "Dotted Names - Basic Interpolation",
"desc": "Dotted names should be considered a form of shorthand for sections.",
"data": {
"person": {
"name": "Joe"
}
},
"template": "\"{{person.name}}\" == \"{{#person}}{{name}}{{/person}}\"",
"expected": "\"Joe\" == \"Joe\""
},
{
"name": "Dotted Names - Triple Mustache Interpolation",
"desc": "Dotted names should be considered a form of shorthand for sections.",
"data": {
"person": {
"name": "Joe"
}
},
"template": "\"{{{person.name}}}\" == \"{{#person}}{{{name}}}{{/person}}\"",
"expected": "\"Joe\" == \"Joe\""
},
{
"name": "Dotted Names - Ampersand Interpolation",
"desc": "Dotted names should be considered a form of shorthand for sections.",
"data": {
"person": {
"name": "Joe"
}
},
"template": "\"{{&person.name}}\" == \"{{#person}}{{&name}}{{/person}}\"",
"expected": "\"Joe\" == \"Joe\""
},
{
"name": "Dotted Names - Arbitrary Depth",
"desc": "Dotted names should be functional to any level of nesting.",
"data": {
"a": {
"b": {
"c": {
"d": {
"e": {
"name": "Phil"
}
}
}
}
}
},
"template": "\"{{a.b.c.d.e.name}}\" == \"Phil\"",
"expected": "\"Phil\" == \"Phil\""
},
{
"name": "Dotted Names - Broken Chains",
"desc": "Any falsey value prior to the last part of the name should yield ''.",
"data": {
"a": {
}
},
"template": "\"{{a.b.c}}\" == \"\"",
"expected": "\"\" == \"\""
},
{
"name": "Dotted Names - Broken Chain Resolution",
"desc": "Each part of a dotted name should resolve only against its parent.",
"data": {
"a": {
"b": {
}
},
"c": {
"name": "Jim"
}
},
"template": "\"{{a.b.c.name}}\" == \"\"",
"expected": "\"\" == \"\""
},
{
"name": "Dotted Names - Initial Resolution",
"desc": "The first part of a dotted name should resolve as any other name.",
"data": {
"a": {
"b": {
"c": {
"d": {
"e": {
"name": "Phil"
}
}
}
}
},
"b": {
"c": {
"d": {
"e": {
"name": "Wrong"
}
}
}
}
},
"template": "\"{{#a}}{{b.c.d.e.name}}{{/a}}\" == \"Phil\"",
"expected": "\"Phil\" == \"Phil\""
},
{
"name": "Dotted Names - Context Precedence",
"desc": "Dotted names should be resolved against former resolutions.",
"data": {
"a": {
"b": {
}
},
"b": {
"c": "ERROR"
}
},
"template": "{{#a}}{{b.c}}{{/a}}",
"expected": ""
},
{
"name": "Implicit Iterators - Basic Interpolation",
"desc": "Unadorned tags should interpolate content into the template.",
"data": "world",
"template": "Hello, {{.}}!\n",
"expected": "Hello, world!\n"
},
{
"name": "Implicit Iterators - HTML Escaping",
"desc": "Basic interpolation should be HTML escaped.",
"data": "& \" < >",
"template": "These characters should be HTML escaped: {{.}}\n",
"expected": "These characters should be HTML escaped: &amp; &quot; &lt; &gt;\n"
},
{
"name": "Implicit Iterators - Triple Mustache",
"desc": "Triple mustaches should interpolate without HTML escaping.",
"data": "& \" < >",
"template": "These characters should not be HTML escaped: {{{.}}}\n",
"expected": "These characters should not be HTML escaped: & \" < >\n"
},
{
"name": "Implicit Iterators - Ampersand",
"desc": "Ampersand should interpolate without HTML escaping.",
"data": "& \" < >",
"template": "These characters should not be HTML escaped: {{&.}}\n",
"expected": "These characters should not be HTML escaped: & \" < >\n"
},
{
"name": "Implicit Iterators - Basic Integer Interpolation",
"desc": "Integers should interpolate seamlessly.",
"data": 85,
"template": "\"{{.}} miles an hour!\"",
"expected": "\"85 miles an hour!\""
},
{
"name": "Interpolation - Surrounding Whitespace",
"desc": "Interpolation should not alter surrounding whitespace.",
"data": {
"string": "---"
},
"template": "| {{string}} |",
"expected": "| --- |"
},
{
"name": "Triple Mustache - Surrounding Whitespace",
"desc": "Interpolation should not alter surrounding whitespace.",
"data": {
"string": "---"
},
"template": "| {{{string}}} |",
"expected": "| --- |"
},
{
"name": "Ampersand - Surrounding Whitespace",
"desc": "Interpolation should not alter surrounding whitespace.",
"data": {
"string": "---"
},
"template": "| {{&string}} |",
"expected": "| --- |"
},
{
"name": "Interpolation - Standalone",
"desc": "Standalone interpolation should not alter surrounding whitespace.",
"data": {
"string": "---"
},
"template": " {{string}}\n",
"expected": " ---\n"
},
{
"name": "Triple Mustache - Standalone",
"desc": "Standalone interpolation should not alter surrounding whitespace.",
"data": {
"string": "---"
},
"template": " {{{string}}}\n",
"expected": " ---\n"
},
{
"name": "Ampersand - Standalone",
"desc": "Standalone interpolation should not alter surrounding whitespace.",
"data": {
"string": "---"
},
"template": " {{&string}}\n",
"expected": " ---\n"
},
{
"name": "Interpolation With Padding",
"desc": "Superfluous in-tag whitespace should be ignored.",
"data": {
"string": "---"
},
"template": "|{{ string }}|",
"expected": "|---|"
},
{
"name": "Triple Mustache With Padding",
"desc": "Superfluous in-tag whitespace should be ignored.",
"data": {
"string": "---"
},
"template": "|{{{ string }}}|",
"expected": "|---|"
},
{
"name": "Ampersand With Padding",
"desc": "Superfluous in-tag whitespace should be ignored.",
"data": {
"string": "---"
},
"template": "|{{& string }}|",
"expected": "|---|"
}
]
}

View File

@ -0,0 +1,296 @@
overview: |
Interpolation tags are used to integrate dynamic content into the template.
The tag's content MUST be a non-whitespace character sequence NOT containing
the current closing delimiter.
This tag's content names the data to replace the tag. A single period (`.`)
indicates that the item currently sitting atop the context stack should be
used; otherwise, name resolution is as follows:
1) Split the name on periods; the first part is the name to resolve, any
remaining parts should be retained.
2) Walk the context stack from top to bottom, finding the first context
that is a) a hash containing the name as a key OR b) an object responding
to a method with the given name.
3) If the context is a hash, the data is the value associated with the
name.
4) If the context is an object, the data is the value returned by the
method with the given name.
5) If any name parts were retained in step 1, each should be resolved
against a context stack containing only the result from the former
resolution. If any part fails resolution, the result should be considered
falsey, and should interpolate as the empty string.
Data should be coerced into a string (and escaped, if appropriate) before
interpolation.
The Interpolation tags MUST NOT be treated as standalone.
tests:
- name: No Interpolation
desc: Mustache-free templates should render as-is.
data: { }
template: |
Hello from {Mustache}!
expected: |
Hello from {Mustache}!
- name: Basic Interpolation
desc: Unadorned tags should interpolate content into the template.
data: { subject: "world" }
template: |
Hello, {{subject}}!
expected: |
Hello, world!
- name: HTML Escaping
desc: Basic interpolation should be HTML escaped.
data: { forbidden: '& " < >' }
template: |
These characters should be HTML escaped: {{forbidden}}
expected: |
These characters should be HTML escaped: &amp; &quot; &lt; &gt;
- name: Triple Mustache
desc: Triple mustaches should interpolate without HTML escaping.
data: { forbidden: '& " < >' }
template: |
These characters should not be HTML escaped: {{{forbidden}}}
expected: |
These characters should not be HTML escaped: & " < >
- name: Ampersand
desc: Ampersand should interpolate without HTML escaping.
data: { forbidden: '& " < >' }
template: |
These characters should not be HTML escaped: {{&forbidden}}
expected: |
These characters should not be HTML escaped: & " < >
- name: Basic Integer Interpolation
desc: Integers should interpolate seamlessly.
data: { mph: 85 }
template: '"{{mph}} miles an hour!"'
expected: '"85 miles an hour!"'
- name: Triple Mustache Integer Interpolation
desc: Integers should interpolate seamlessly.
data: { mph: 85 }
template: '"{{{mph}}} miles an hour!"'
expected: '"85 miles an hour!"'
- name: Ampersand Integer Interpolation
desc: Integers should interpolate seamlessly.
data: { mph: 85 }
template: '"{{&mph}} miles an hour!"'
expected: '"85 miles an hour!"'
- name: Basic Decimal Interpolation
desc: Decimals should interpolate seamlessly with proper significance.
data: { power: 1.210 }
template: '"{{power}} jiggawatts!"'
expected: '"1.21 jiggawatts!"'
- name: Triple Mustache Decimal Interpolation
desc: Decimals should interpolate seamlessly with proper significance.
data: { power: 1.210 }
template: '"{{{power}}} jiggawatts!"'
expected: '"1.21 jiggawatts!"'
- name: Ampersand Decimal Interpolation
desc: Decimals should interpolate seamlessly with proper significance.
data: { power: 1.210 }
template: '"{{&power}} jiggawatts!"'
expected: '"1.21 jiggawatts!"'
- name: Basic Null Interpolation
desc: Nulls should interpolate as the empty string.
data: { cannot: null }
template: "I ({{cannot}}) be seen!"
expected: "I () be seen!"
- name: Triple Mustache Null Interpolation
desc: Nulls should interpolate as the empty string.
data: { cannot: null }
template: "I ({{{cannot}}}) be seen!"
expected: "I () be seen!"
- name: Ampersand Null Interpolation
desc: Nulls should interpolate as the empty string.
data: { cannot: null }
template: "I ({{&cannot}}) be seen!"
expected: "I () be seen!"
# Context Misses
- name: Basic Context Miss Interpolation
desc: Failed context lookups should default to empty strings.
data: { }
template: "I ({{cannot}}) be seen!"
expected: "I () be seen!"
- name: Triple Mustache Context Miss Interpolation
desc: Failed context lookups should default to empty strings.
data: { }
template: "I ({{{cannot}}}) be seen!"
expected: "I () be seen!"
- name: Ampersand Context Miss Interpolation
desc: Failed context lookups should default to empty strings.
data: { }
template: "I ({{&cannot}}) be seen!"
expected: "I () be seen!"
# Dotted Names
- name: Dotted Names - Basic Interpolation
desc: Dotted names should be considered a form of shorthand for sections.
data: { person: { name: 'Joe' } }
template: '"{{person.name}}" == "{{#person}}{{name}}{{/person}}"'
expected: '"Joe" == "Joe"'
- name: Dotted Names - Triple Mustache Interpolation
desc: Dotted names should be considered a form of shorthand for sections.
data: { person: { name: 'Joe' } }
template: '"{{{person.name}}}" == "{{#person}}{{{name}}}{{/person}}"'
expected: '"Joe" == "Joe"'
- name: Dotted Names - Ampersand Interpolation
desc: Dotted names should be considered a form of shorthand for sections.
data: { person: { name: 'Joe' } }
template: '"{{&person.name}}" == "{{#person}}{{&name}}{{/person}}"'
expected: '"Joe" == "Joe"'
- name: Dotted Names - Arbitrary Depth
desc: Dotted names should be functional to any level of nesting.
data:
a: { b: { c: { d: { e: { name: 'Phil' } } } } }
template: '"{{a.b.c.d.e.name}}" == "Phil"'
expected: '"Phil" == "Phil"'
- name: Dotted Names - Broken Chains
desc: Any falsey value prior to the last part of the name should yield ''.
data:
a: { }
template: '"{{a.b.c}}" == ""'
expected: '"" == ""'
- name: Dotted Names - Broken Chain Resolution
desc: Each part of a dotted name should resolve only against its parent.
data:
a: { b: { } }
c: { name: 'Jim' }
template: '"{{a.b.c.name}}" == ""'
expected: '"" == ""'
- name: Dotted Names - Initial Resolution
desc: The first part of a dotted name should resolve as any other name.
data:
a: { b: { c: { d: { e: { name: 'Phil' } } } } }
b: { c: { d: { e: { name: 'Wrong' } } } }
template: '"{{#a}}{{b.c.d.e.name}}{{/a}}" == "Phil"'
expected: '"Phil" == "Phil"'
- name: Dotted Names - Context Precedence
desc: Dotted names should be resolved against former resolutions.
data:
a: { b: { } }
b: { c: 'ERROR' }
template: '{{#a}}{{b.c}}{{/a}}'
expected: ''
# Implicit Iterators
- name: Implicit Iterators - Basic Interpolation
desc: Unadorned tags should interpolate content into the template.
data: "world"
template: |
Hello, {{.}}!
expected: |
Hello, world!
- name: Implicit Iterators - HTML Escaping
desc: Basic interpolation should be HTML escaped.
data: '& " < >'
template: |
These characters should be HTML escaped: {{.}}
expected: |
These characters should be HTML escaped: &amp; &quot; &lt; &gt;
- name: Implicit Iterators - Triple Mustache
desc: Triple mustaches should interpolate without HTML escaping.
data: '& " < >'
template: |
These characters should not be HTML escaped: {{{.}}}
expected: |
These characters should not be HTML escaped: & " < >
- name: Implicit Iterators - Ampersand
desc: Ampersand should interpolate without HTML escaping.
data: '& " < >'
template: |
These characters should not be HTML escaped: {{&.}}
expected: |
These characters should not be HTML escaped: & " < >
- name: Implicit Iterators - Basic Integer Interpolation
desc: Integers should interpolate seamlessly.
data: 85
template: '"{{.}} miles an hour!"'
expected: '"85 miles an hour!"'
# Whitespace Sensitivity
- name: Interpolation - Surrounding Whitespace
desc: Interpolation should not alter surrounding whitespace.
data: { string: '---' }
template: '| {{string}} |'
expected: '| --- |'
- name: Triple Mustache - Surrounding Whitespace
desc: Interpolation should not alter surrounding whitespace.
data: { string: '---' }
template: '| {{{string}}} |'
expected: '| --- |'
- name: Ampersand - Surrounding Whitespace
desc: Interpolation should not alter surrounding whitespace.
data: { string: '---' }
template: '| {{&string}} |'
expected: '| --- |'
- name: Interpolation - Standalone
desc: Standalone interpolation should not alter surrounding whitespace.
data: { string: '---' }
template: " {{string}}\n"
expected: " ---\n"
- name: Triple Mustache - Standalone
desc: Standalone interpolation should not alter surrounding whitespace.
data: { string: '---' }
template: " {{{string}}}\n"
expected: " ---\n"
- name: Ampersand - Standalone
desc: Standalone interpolation should not alter surrounding whitespace.
data: { string: '---' }
template: " {{&string}}\n"
expected: " ---\n"
# Whitespace Insensitivity
- name: Interpolation With Padding
desc: Superfluous in-tag whitespace should be ignored.
data: { string: "---" }
template: '|{{ string }}|'
expected: '|---|'
- name: Triple Mustache With Padding
desc: Superfluous in-tag whitespace should be ignored.
data: { string: "---" }
template: '|{{{ string }}}|'
expected: '|---|'
- name: Ampersand With Padding
desc: Superfluous in-tag whitespace should be ignored.
data: { string: "---" }
template: '|{{& string }}|'
expected: '|---|'

View File

@ -0,0 +1,227 @@
{
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
"overview": "Inverted Section tags and End Section tags are used in combination to wrap a\nsection of the template.\n\nThese tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Inverted Section tag MUST be\nfollowed by an End Section tag with the same content within the same\nsection.\n\nThis tag's content names the data to replace the tag. Name resolution is as\nfollows:\n 1) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 2) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 3) If the context is a hash, the data is the value associated with the\n name.\n 4) If the context is an object and the method with the given name has an\n arity of 1, the method SHOULD be called with a String containing the\n unprocessed contents of the sections; the data is the value returned.\n 5) Otherwise, the data is the value returned by calling the method with\n the given name.\n 6) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\nIf the data is not of a list type, it is coerced into a list as follows: if\nthe data is truthy (e.g. `!!data == true`), use a single-element list\ncontaining the data, otherwise use an empty list.\n\nThis section MUST NOT be rendered unless the data list is empty.\n\nInverted Section and End Section tags SHOULD be treated as standalone when\nappropriate.\n",
"tests": [
{
"name": "Falsey",
"desc": "Falsey sections should have their contents rendered.",
"data": {
"boolean": false
},
"template": "\"{{^boolean}}This should be rendered.{{/boolean}}\"",
"expected": "\"This should be rendered.\""
},
{
"name": "Truthy",
"desc": "Truthy sections should have their contents omitted.",
"data": {
"boolean": true
},
"template": "\"{{^boolean}}This should not be rendered.{{/boolean}}\"",
"expected": "\"\""
},
{
"name": "Null is falsey",
"desc": "Null is falsey.",
"data": {
"null": null
},
"template": "\"{{^null}}This should be rendered.{{/null}}\"",
"expected": "\"This should be rendered.\""
},
{
"name": "Context",
"desc": "Objects and hashes should behave like truthy values.",
"data": {
"context": {
"name": "Joe"
}
},
"template": "\"{{^context}}Hi {{name}}.{{/context}}\"",
"expected": "\"\""
},
{
"name": "List",
"desc": "Lists should behave like truthy values.",
"data": {
"list": [
{
"n": 1
},
{
"n": 2
},
{
"n": 3
}
]
},
"template": "\"{{^list}}{{n}}{{/list}}\"",
"expected": "\"\""
},
{
"name": "Empty List",
"desc": "Empty lists should behave like falsey values.",
"data": {
"list": [
]
},
"template": "\"{{^list}}Yay lists!{{/list}}\"",
"expected": "\"Yay lists!\""
},
{
"name": "Doubled",
"desc": "Multiple inverted sections per template should be permitted.",
"data": {
"bool": false,
"two": "second"
},
"template": "{{^bool}}\n* first\n{{/bool}}\n* {{two}}\n{{^bool}}\n* third\n{{/bool}}\n",
"expected": "* first\n* second\n* third\n"
},
{
"name": "Nested (Falsey)",
"desc": "Nested falsey sections should have their contents rendered.",
"data": {
"bool": false
},
"template": "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |",
"expected": "| A B C D E |"
},
{
"name": "Nested (Truthy)",
"desc": "Nested truthy sections should be omitted.",
"data": {
"bool": true
},
"template": "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |",
"expected": "| A E |"
},
{
"name": "Context Misses",
"desc": "Failed context lookups should be considered falsey.",
"data": {
},
"template": "[{{^missing}}Cannot find key 'missing'!{{/missing}}]",
"expected": "[Cannot find key 'missing'!]"
},
{
"name": "Dotted Names - Truthy",
"desc": "Dotted names should be valid for Inverted Section tags.",
"data": {
"a": {
"b": {
"c": true
}
}
},
"template": "\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"\"",
"expected": "\"\" == \"\""
},
{
"name": "Dotted Names - Falsey",
"desc": "Dotted names should be valid for Inverted Section tags.",
"data": {
"a": {
"b": {
"c": false
}
}
},
"template": "\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"Not Here\"",
"expected": "\"Not Here\" == \"Not Here\""
},
{
"name": "Dotted Names - Broken Chains",
"desc": "Dotted names that cannot be resolved should be considered falsey.",
"data": {
"a": {
}
},
"template": "\"{{^a.b.c}}Not Here{{/a.b.c}}\" == \"Not Here\"",
"expected": "\"Not Here\" == \"Not Here\""
},
{
"name": "Surrounding Whitespace",
"desc": "Inverted sections should not alter surrounding whitespace.",
"data": {
"boolean": false
},
"template": " | {{^boolean}}\t|\t{{/boolean}} | \n",
"expected": " | \t|\t | \n"
},
{
"name": "Internal Whitespace",
"desc": "Inverted should not alter internal whitespace.",
"data": {
"boolean": false
},
"template": " | {{^boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n",
"expected": " | \n | \n"
},
{
"name": "Indented Inline Sections",
"desc": "Single-line sections should not alter surrounding whitespace.",
"data": {
"boolean": false
},
"template": " {{^boolean}}NO{{/boolean}}\n {{^boolean}}WAY{{/boolean}}\n",
"expected": " NO\n WAY\n"
},
{
"name": "Standalone Lines",
"desc": "Standalone lines should be removed from the template.",
"data": {
"boolean": false
},
"template": "| This Is\n{{^boolean}}\n|\n{{/boolean}}\n| A Line\n",
"expected": "| This Is\n|\n| A Line\n"
},
{
"name": "Standalone Indented Lines",
"desc": "Standalone indented lines should be removed from the template.",
"data": {
"boolean": false
},
"template": "| This Is\n {{^boolean}}\n|\n {{/boolean}}\n| A Line\n",
"expected": "| This Is\n|\n| A Line\n"
},
{
"name": "Standalone Line Endings",
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
"data": {
"boolean": false
},
"template": "|\r\n{{^boolean}}\r\n{{/boolean}}\r\n|",
"expected": "|\r\n|"
},
{
"name": "Standalone Without Previous Line",
"desc": "Standalone tags should not require a newline to precede them.",
"data": {
"boolean": false
},
"template": " {{^boolean}}\n^{{/boolean}}\n/",
"expected": "^\n/"
},
{
"name": "Standalone Without Newline",
"desc": "Standalone tags should not require a newline to follow them.",
"data": {
"boolean": false
},
"template": "^{{^boolean}}\n/\n {{/boolean}}",
"expected": "^\n/\n"
},
{
"name": "Padding",
"desc": "Superfluous in-tag whitespace should be ignored.",
"data": {
"boolean": false
},
"template": "|{{^ boolean }}={{/ boolean }}|",
"expected": "|=|"
}
]
}

199
mustache/specs/inverted.yml Normal file
View File

@ -0,0 +1,199 @@
overview: |
Inverted Section tags and End Section tags are used in combination to wrap a
section of the template.
These tags' content MUST be a non-whitespace character sequence NOT
containing the current closing delimiter; each Inverted Section tag MUST be
followed by an End Section tag with the same content within the same
section.
This tag's content names the data to replace the tag. Name resolution is as
follows:
1) Split the name on periods; the first part is the name to resolve, any
remaining parts should be retained.
2) Walk the context stack from top to bottom, finding the first context
that is a) a hash containing the name as a key OR b) an object responding
to a method with the given name.
3) If the context is a hash, the data is the value associated with the
name.
4) If the context is an object and the method with the given name has an
arity of 1, the method SHOULD be called with a String containing the
unprocessed contents of the sections; the data is the value returned.
5) Otherwise, the data is the value returned by calling the method with
the given name.
6) If any name parts were retained in step 1, each should be resolved
against a context stack containing only the result from the former
resolution. If any part fails resolution, the result should be considered
falsey, and should interpolate as the empty string.
If the data is not of a list type, it is coerced into a list as follows: if
the data is truthy (e.g. `!!data == true`), use a single-element list
containing the data, otherwise use an empty list.
This section MUST NOT be rendered unless the data list is empty.
Inverted Section and End Section tags SHOULD be treated as standalone when
appropriate.
tests:
- name: Falsey
desc: Falsey sections should have their contents rendered.
data: { boolean: false }
template: '"{{^boolean}}This should be rendered.{{/boolean}}"'
expected: '"This should be rendered."'
- name: Truthy
desc: Truthy sections should have their contents omitted.
data: { boolean: true }
template: '"{{^boolean}}This should not be rendered.{{/boolean}}"'
expected: '""'
- name: Null is falsey
desc: Null is falsey.
data: { "null": null }
template: '"{{^null}}This should be rendered.{{/null}}"'
expected: '"This should be rendered."'
- name: Context
desc: Objects and hashes should behave like truthy values.
data: { context: { name: 'Joe' } }
template: '"{{^context}}Hi {{name}}.{{/context}}"'
expected: '""'
- name: List
desc: Lists should behave like truthy values.
data: { list: [ { n: 1 }, { n: 2 }, { n: 3 } ] }
template: '"{{^list}}{{n}}{{/list}}"'
expected: '""'
- name: Empty List
desc: Empty lists should behave like falsey values.
data: { list: [ ] }
template: '"{{^list}}Yay lists!{{/list}}"'
expected: '"Yay lists!"'
- name: Doubled
desc: Multiple inverted sections per template should be permitted.
data: { bool: false, two: 'second' }
template: |
{{^bool}}
* first
{{/bool}}
* {{two}}
{{^bool}}
* third
{{/bool}}
expected: |
* first
* second
* third
- name: Nested (Falsey)
desc: Nested falsey sections should have their contents rendered.
data: { bool: false }
template: "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |"
expected: "| A B C D E |"
- name: Nested (Truthy)
desc: Nested truthy sections should be omitted.
data: { bool: true }
template: "| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |"
expected: "| A E |"
- name: Context Misses
desc: Failed context lookups should be considered falsey.
data: { }
template: "[{{^missing}}Cannot find key 'missing'!{{/missing}}]"
expected: "[Cannot find key 'missing'!]"
# Dotted Names
- name: Dotted Names - Truthy
desc: Dotted names should be valid for Inverted Section tags.
data: { a: { b: { c: true } } }
template: '"{{^a.b.c}}Not Here{{/a.b.c}}" == ""'
expected: '"" == ""'
- name: Dotted Names - Falsey
desc: Dotted names should be valid for Inverted Section tags.
data: { a: { b: { c: false } } }
template: '"{{^a.b.c}}Not Here{{/a.b.c}}" == "Not Here"'
expected: '"Not Here" == "Not Here"'
- name: Dotted Names - Broken Chains
desc: Dotted names that cannot be resolved should be considered falsey.
data: { a: { } }
template: '"{{^a.b.c}}Not Here{{/a.b.c}}" == "Not Here"'
expected: '"Not Here" == "Not Here"'
# Whitespace Sensitivity
- name: Surrounding Whitespace
desc: Inverted sections should not alter surrounding whitespace.
data: { boolean: false }
template: " | {{^boolean}}\t|\t{{/boolean}} | \n"
expected: " | \t|\t | \n"
- name: Internal Whitespace
desc: Inverted should not alter internal whitespace.
data: { boolean: false }
template: " | {{^boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n"
expected: " | \n | \n"
- name: Indented Inline Sections
desc: Single-line sections should not alter surrounding whitespace.
data: { boolean: false }
template: " {{^boolean}}NO{{/boolean}}\n {{^boolean}}WAY{{/boolean}}\n"
expected: " NO\n WAY\n"
- name: Standalone Lines
desc: Standalone lines should be removed from the template.
data: { boolean: false }
template: |
| This Is
{{^boolean}}
|
{{/boolean}}
| A Line
expected: |
| This Is
|
| A Line
- name: Standalone Indented Lines
desc: Standalone indented lines should be removed from the template.
data: { boolean: false }
template: |
| This Is
{{^boolean}}
|
{{/boolean}}
| A Line
expected: |
| This Is
|
| A Line
- name: Standalone Line Endings
desc: '"\r\n" should be considered a newline for standalone tags.'
data: { boolean: false }
template: "|\r\n{{^boolean}}\r\n{{/boolean}}\r\n|"
expected: "|\r\n|"
- name: Standalone Without Previous Line
desc: Standalone tags should not require a newline to precede them.
data: { boolean: false }
template: " {{^boolean}}\n^{{/boolean}}\n/"
expected: "^\n/"
- name: Standalone Without Newline
desc: Standalone tags should not require a newline to follow them.
data: { boolean: false }
template: "^{{^boolean}}\n/\n {{/boolean}}"
expected: "^\n/\n"
# Whitespace Insensitivity
- name: Padding
desc: Superfluous in-tag whitespace should be ignored.
data: { boolean: false }
template: '|{{^ boolean }}={{/ boolean }}|'
expected: '|=|'

View File

@ -0,0 +1,139 @@
{
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
"overview": "Partial tags are used to expand an external template into the current\ntemplate.\n\nThe tag's content MUST be a non-whitespace character sequence NOT containing\nthe current closing delimiter.\n\nThis tag's content names the partial to inject. Set Delimiter tags MUST NOT\naffect the parsing of a partial. The partial MUST be rendered against the\ncontext stack local to the tag. If the named partial cannot be found, the\nempty string SHOULD be used instead, as in interpolations.\n\nPartial tags SHOULD be treated as standalone when appropriate. If this tag\nis used standalone, any whitespace preceding the tag should treated as\nindentation, and prepended to each line of the partial before rendering.\n",
"tests": [
{
"name": "Basic Behavior",
"desc": "The greater-than operator should expand to the named partial.",
"data": {
},
"template": "\"{{>text}}\"",
"partials": {
"text": "from partial"
},
"expected": "\"from partial\""
},
{
"name": "Failed Lookup",
"desc": "The empty string should be used when the named partial is not found.",
"data": {
},
"template": "\"{{>text}}\"",
"partials": {
},
"expected": "\"\""
},
{
"name": "Context",
"desc": "The greater-than operator should operate within the current context.",
"data": {
"text": "content"
},
"template": "\"{{>partial}}\"",
"partials": {
"partial": "*{{text}}*"
},
"expected": "\"*content*\""
},
{
"name": "Recursion",
"desc": "The greater-than operator should properly recurse.",
"data": {
"content": "X",
"nodes": [
{
"content": "Y",
"nodes": [
]
}
]
},
"template": "{{>node}}",
"partials": {
"node": "{{content}}<{{#nodes}}{{>node}}{{/nodes}}>"
},
"expected": "X<Y<>>"
},
{
"name": "Surrounding Whitespace",
"desc": "The greater-than operator should not alter surrounding whitespace.",
"data": {
},
"template": "| {{>partial}} |",
"partials": {
"partial": "\t|\t"
},
"expected": "| \t|\t |"
},
{
"name": "Inline Indentation",
"desc": "Whitespace should be left untouched.",
"data": {
"data": "|"
},
"template": " {{data}} {{> partial}}\n",
"partials": {
"partial": ">\n>"
},
"expected": " | >\n>\n"
},
{
"name": "Standalone Line Endings",
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
"data": {
},
"template": "|\r\n{{>partial}}\r\n|",
"partials": {
"partial": ">"
},
"expected": "|\r\n>|"
},
{
"name": "Standalone Without Previous Line",
"desc": "Standalone tags should not require a newline to precede them.",
"data": {
},
"template": " {{>partial}}\n>",
"partials": {
"partial": ">\n>"
},
"expected": " >\n >>"
},
{
"name": "Standalone Without Newline",
"desc": "Standalone tags should not require a newline to follow them.",
"data": {
},
"template": ">\n {{>partial}}",
"partials": {
"partial": ">\n>"
},
"expected": ">\n >\n >"
},
{
"name": "Standalone Indentation",
"desc": "Each line of the partial should be indented before rendering.",
"data": {
"content": "<\n->"
},
"template": "\\\n {{>partial}}\n/\n",
"partials": {
"partial": "|\n{{{content}}}\n|\n"
},
"expected": "\\\n |\n <\n->\n |\n/\n"
},
{
"name": "Padding Whitespace",
"desc": "Superfluous in-tag whitespace should be ignored.",
"data": {
"boolean": true
},
"template": "|{{> partial }}|",
"partials": {
"partial": "[]"
},
"expected": "|[]|"
}
]
}

109
mustache/specs/partials.yml Normal file
View File

@ -0,0 +1,109 @@
overview: |
Partial tags are used to expand an external template into the current
template.
The tag's content MUST be a non-whitespace character sequence NOT containing
the current closing delimiter.
This tag's content names the partial to inject. Set Delimiter tags MUST NOT
affect the parsing of a partial. The partial MUST be rendered against the
context stack local to the tag. If the named partial cannot be found, the
empty string SHOULD be used instead, as in interpolations.
Partial tags SHOULD be treated as standalone when appropriate. If this tag
is used standalone, any whitespace preceding the tag should treated as
indentation, and prepended to each line of the partial before rendering.
tests:
- name: Basic Behavior
desc: The greater-than operator should expand to the named partial.
data: { }
template: '"{{>text}}"'
partials: { text: 'from partial' }
expected: '"from partial"'
- name: Failed Lookup
desc: The empty string should be used when the named partial is not found.
data: { }
template: '"{{>text}}"'
partials: { }
expected: '""'
- name: Context
desc: The greater-than operator should operate within the current context.
data: { text: 'content' }
template: '"{{>partial}}"'
partials: { partial: '*{{text}}*' }
expected: '"*content*"'
- name: Recursion
desc: The greater-than operator should properly recurse.
data: { content: "X", nodes: [ { content: "Y", nodes: [] } ] }
template: '{{>node}}'
partials: { node: '{{content}}<{{#nodes}}{{>node}}{{/nodes}}>' }
expected: 'X<Y<>>'
# Whitespace Sensitivity
- name: Surrounding Whitespace
desc: The greater-than operator should not alter surrounding whitespace.
data: { }
template: '| {{>partial}} |'
partials: { partial: "\t|\t" }
expected: "| \t|\t |"
- name: Inline Indentation
desc: Whitespace should be left untouched.
data: { data: '|' }
template: " {{data}} {{> partial}}\n"
partials: { partial: ">\n>" }
expected: " | >\n>\n"
- name: Standalone Line Endings
desc: '"\r\n" should be considered a newline for standalone tags.'
data: { }
template: "|\r\n{{>partial}}\r\n|"
partials: { partial: ">" }
expected: "|\r\n>|"
- name: Standalone Without Previous Line
desc: Standalone tags should not require a newline to precede them.
data: { }
template: " {{>partial}}\n>"
partials: { partial: ">\n>"}
expected: " >\n >>"
- name: Standalone Without Newline
desc: Standalone tags should not require a newline to follow them.
data: { }
template: ">\n {{>partial}}"
partials: { partial: ">\n>" }
expected: ">\n >\n >"
- name: Standalone Indentation
desc: Each line of the partial should be indented before rendering.
data: { content: "<\n->" }
template: |
\
{{>partial}}
/
partials:
partial: |
|
{{{content}}}
|
expected: |
\
|
<
->
|
/
# Whitespace Insensitivity
- name: Padding Whitespace
desc: Superfluous in-tag whitespace should be ignored.
data: { boolean: true }
template: "|{{> partial }}|"
partials: { partial: "[]" }
expected: '|[]|'

View File

@ -0,0 +1,367 @@
{
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
"overview": "Section tags and End Section tags are used in combination to wrap a section\nof the template for iteration\n\nThese tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Section tag MUST be followed\nby an End Section tag with the same content within the same section.\n\nThis tag's content names the data to replace the tag. Name resolution is as\nfollows:\n 1) Split the name on periods; the first part is the name to resolve, any\n remaining parts should be retained.\n 2) Walk the context stack from top to bottom, finding the first context\n that is a) a hash containing the name as a key OR b) an object responding\n to a method with the given name.\n 3) If the context is a hash, the data is the value associated with the\n name.\n 4) If the context is an object and the method with the given name has an\n arity of 1, the method SHOULD be called with a String containing the\n unprocessed contents of the sections; the data is the value returned.\n 5) Otherwise, the data is the value returned by calling the method with\n the given name.\n 6) If any name parts were retained in step 1, each should be resolved\n against a context stack containing only the result from the former\n resolution. If any part fails resolution, the result should be considered\n falsey, and should interpolate as the empty string.\nIf the data is not of a list type, it is coerced into a list as follows: if\nthe data is truthy (e.g. `!!data == true`), use a single-element list\ncontaining the data, otherwise use an empty list.\n\nFor each element in the data list, the element MUST be pushed onto the\ncontext stack, the section MUST be rendered, and the element MUST be popped\noff the context stack.\n\nSection and End Section tags SHOULD be treated as standalone when\nappropriate.\n",
"tests": [
{
"name": "Truthy",
"desc": "Truthy sections should have their contents rendered.",
"data": {
"boolean": true
},
"template": "\"{{#boolean}}This should be rendered.{{/boolean}}\"",
"expected": "\"This should be rendered.\""
},
{
"name": "Falsey",
"desc": "Falsey sections should have their contents omitted.",
"data": {
"boolean": false
},
"template": "\"{{#boolean}}This should not be rendered.{{/boolean}}\"",
"expected": "\"\""
},
{
"name": "Null is falsey",
"desc": "Null is falsey.",
"data": {
"null": null
},
"template": "\"{{#null}}This should not be rendered.{{/null}}\"",
"expected": "\"\""
},
{
"name": "Context",
"desc": "Objects and hashes should be pushed onto the context stack.",
"data": {
"context": {
"name": "Joe"
}
},
"template": "\"{{#context}}Hi {{name}}.{{/context}}\"",
"expected": "\"Hi Joe.\""
},
{
"name": "Parent contexts",
"desc": "Names missing in the current context are looked up in the stack.",
"data": {
"a": "foo",
"b": "wrong",
"sec": {
"b": "bar"
},
"c": {
"d": "baz"
}
},
"template": "\"{{#sec}}{{a}}, {{b}}, {{c.d}}{{/sec}}\"",
"expected": "\"foo, bar, baz\""
},
{
"name": "Variable test",
"desc": "Non-false sections have their value at the top of context,\naccessible as {{.}} or through the parent context. This gives\na simple way to display content conditionally if a variable exists.\n",
"data": {
"foo": "bar"
},
"template": "\"{{#foo}}{{.}} is {{foo}}{{/foo}}\"",
"expected": "\"bar is bar\""
},
{
"name": "List Contexts",
"desc": "All elements on the context stack should be accessible within lists.",
"data": {
"tops": [
{
"tname": {
"upper": "A",
"lower": "a"
},
"middles": [
{
"mname": "1",
"bottoms": [
{
"bname": "x"
},
{
"bname": "y"
}
]
}
]
}
]
},
"template": "{{#tops}}{{#middles}}{{tname.lower}}{{mname}}.{{#bottoms}}{{tname.upper}}{{mname}}{{bname}}.{{/bottoms}}{{/middles}}{{/tops}}",
"expected": "a1.A1x.A1y."
},
{
"name": "Deeply Nested Contexts",
"desc": "All elements on the context stack should be accessible.",
"data": {
"a": {
"one": 1
},
"b": {
"two": 2
},
"c": {
"three": 3,
"d": {
"four": 4,
"five": 5
}
}
},
"template": "{{#a}}\n{{one}}\n{{#b}}\n{{one}}{{two}}{{one}}\n{{#c}}\n{{one}}{{two}}{{three}}{{two}}{{one}}\n{{#d}}\n{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}\n{{#five}}\n{{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}}\n{{one}}{{two}}{{three}}{{four}}{{.}}6{{.}}{{four}}{{three}}{{two}}{{one}}\n{{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}}\n{{/five}}\n{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}\n{{/d}}\n{{one}}{{two}}{{three}}{{two}}{{one}}\n{{/c}}\n{{one}}{{two}}{{one}}\n{{/b}}\n{{one}}\n{{/a}}\n",
"expected": "1\n121\n12321\n1234321\n123454321\n12345654321\n123454321\n1234321\n12321\n121\n1\n"
},
{
"name": "List",
"desc": "Lists should be iterated; list items should visit the context stack.",
"data": {
"list": [
{
"item": 1
},
{
"item": 2
},
{
"item": 3
}
]
},
"template": "\"{{#list}}{{item}}{{/list}}\"",
"expected": "\"123\""
},
{
"name": "Empty List",
"desc": "Empty lists should behave like falsey values.",
"data": {
"list": [
]
},
"template": "\"{{#list}}Yay lists!{{/list}}\"",
"expected": "\"\""
},
{
"name": "Doubled",
"desc": "Multiple sections per template should be permitted.",
"data": {
"bool": true,
"two": "second"
},
"template": "{{#bool}}\n* first\n{{/bool}}\n* {{two}}\n{{#bool}}\n* third\n{{/bool}}\n",
"expected": "* first\n* second\n* third\n"
},
{
"name": "Nested (Truthy)",
"desc": "Nested truthy sections should have their contents rendered.",
"data": {
"bool": true
},
"template": "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |",
"expected": "| A B C D E |"
},
{
"name": "Nested (Falsey)",
"desc": "Nested falsey sections should be omitted.",
"data": {
"bool": false
},
"template": "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |",
"expected": "| A E |"
},
{
"name": "Context Misses",
"desc": "Failed context lookups should be considered falsey.",
"data": {
},
"template": "[{{#missing}}Found key 'missing'!{{/missing}}]",
"expected": "[]"
},
{
"name": "Implicit Iterator - String",
"desc": "Implicit iterators should directly interpolate strings.",
"data": {
"list": [
"a",
"b",
"c",
"d",
"e"
]
},
"template": "\"{{#list}}({{.}}){{/list}}\"",
"expected": "\"(a)(b)(c)(d)(e)\""
},
{
"name": "Implicit Iterator - Integer",
"desc": "Implicit iterators should cast integers to strings and interpolate.",
"data": {
"list": [
1,
2,
3,
4,
5
]
},
"template": "\"{{#list}}({{.}}){{/list}}\"",
"expected": "\"(1)(2)(3)(4)(5)\""
},
{
"name": "Implicit Iterator - Decimal",
"desc": "Implicit iterators should cast decimals to strings and interpolate.",
"data": {
"list": [
1.1,
2.2,
3.3,
4.4,
5.5
]
},
"template": "\"{{#list}}({{.}}){{/list}}\"",
"expected": "\"(1.1)(2.2)(3.3)(4.4)(5.5)\""
},
{
"name": "Implicit Iterator - Array",
"desc": "Implicit iterators should allow iterating over nested arrays.",
"data": {
"list": [
[
1,
2,
3
],
[
"a",
"b",
"c"
]
]
},
"template": "\"{{#list}}({{#.}}{{.}}{{/.}}){{/list}}\"",
"expected": "\"(123)(abc)\""
},
{
"name": "Dotted Names - Truthy",
"desc": "Dotted names should be valid for Section tags.",
"data": {
"a": {
"b": {
"c": true
}
}
},
"template": "\"{{#a.b.c}}Here{{/a.b.c}}\" == \"Here\"",
"expected": "\"Here\" == \"Here\""
},
{
"name": "Dotted Names - Falsey",
"desc": "Dotted names should be valid for Section tags.",
"data": {
"a": {
"b": {
"c": false
}
}
},
"template": "\"{{#a.b.c}}Here{{/a.b.c}}\" == \"\"",
"expected": "\"\" == \"\""
},
{
"name": "Dotted Names - Broken Chains",
"desc": "Dotted names that cannot be resolved should be considered falsey.",
"data": {
"a": {
}
},
"template": "\"{{#a.b.c}}Here{{/a.b.c}}\" == \"\"",
"expected": "\"\" == \"\""
},
{
"name": "Surrounding Whitespace",
"desc": "Sections should not alter surrounding whitespace.",
"data": {
"boolean": true
},
"template": " | {{#boolean}}\t|\t{{/boolean}} | \n",
"expected": " | \t|\t | \n"
},
{
"name": "Internal Whitespace",
"desc": "Sections should not alter internal whitespace.",
"data": {
"boolean": true
},
"template": " | {{#boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n",
"expected": " | \n | \n"
},
{
"name": "Indented Inline Sections",
"desc": "Single-line sections should not alter surrounding whitespace.",
"data": {
"boolean": true
},
"template": " {{#boolean}}YES{{/boolean}}\n {{#boolean}}GOOD{{/boolean}}\n",
"expected": " YES\n GOOD\n"
},
{
"name": "Standalone Lines",
"desc": "Standalone lines should be removed from the template.",
"data": {
"boolean": true
},
"template": "| This Is\n{{#boolean}}\n|\n{{/boolean}}\n| A Line\n",
"expected": "| This Is\n|\n| A Line\n"
},
{
"name": "Indented Standalone Lines",
"desc": "Indented standalone lines should be removed from the template.",
"data": {
"boolean": true
},
"template": "| This Is\n {{#boolean}}\n|\n {{/boolean}}\n| A Line\n",
"expected": "| This Is\n|\n| A Line\n"
},
{
"name": "Standalone Line Endings",
"desc": "\"\\r\\n\" should be considered a newline for standalone tags.",
"data": {
"boolean": true
},
"template": "|\r\n{{#boolean}}\r\n{{/boolean}}\r\n|",
"expected": "|\r\n|"
},
{
"name": "Standalone Without Previous Line",
"desc": "Standalone tags should not require a newline to precede them.",
"data": {
"boolean": true
},
"template": " {{#boolean}}\n#{{/boolean}}\n/",
"expected": "#\n/"
},
{
"name": "Standalone Without Newline",
"desc": "Standalone tags should not require a newline to follow them.",
"data": {
"boolean": true
},
"template": "#{{#boolean}}\n/\n {{/boolean}}",
"expected": "#\n/\n"
},
{
"name": "Padding",
"desc": "Superfluous in-tag whitespace should be ignored.",
"data": {
"boolean": true
},
"template": "|{{# boolean }}={{/ boolean }}|",
"expected": "|=|"
}
]
}

301
mustache/specs/sections.yml Normal file
View File

@ -0,0 +1,301 @@
overview: |
Section tags and End Section tags are used in combination to wrap a section
of the template for iteration
These tags' content MUST be a non-whitespace character sequence NOT
containing the current closing delimiter; each Section tag MUST be followed
by an End Section tag with the same content within the same section.
This tag's content names the data to replace the tag. Name resolution is as
follows:
1) Split the name on periods; the first part is the name to resolve, any
remaining parts should be retained.
2) Walk the context stack from top to bottom, finding the first context
that is a) a hash containing the name as a key OR b) an object responding
to a method with the given name.
3) If the context is a hash, the data is the value associated with the
name.
4) If the context is an object and the method with the given name has an
arity of 1, the method SHOULD be called with a String containing the
unprocessed contents of the sections; the data is the value returned.
5) Otherwise, the data is the value returned by calling the method with
the given name.
6) If any name parts were retained in step 1, each should be resolved
against a context stack containing only the result from the former
resolution. If any part fails resolution, the result should be considered
falsey, and should interpolate as the empty string.
If the data is not of a list type, it is coerced into a list as follows: if
the data is truthy (e.g. `!!data == true`), use a single-element list
containing the data, otherwise use an empty list.
For each element in the data list, the element MUST be pushed onto the
context stack, the section MUST be rendered, and the element MUST be popped
off the context stack.
Section and End Section tags SHOULD be treated as standalone when
appropriate.
tests:
- name: Truthy
desc: Truthy sections should have their contents rendered.
data: { boolean: true }
template: '"{{#boolean}}This should be rendered.{{/boolean}}"'
expected: '"This should be rendered."'
- name: Falsey
desc: Falsey sections should have their contents omitted.
data: { boolean: false }
template: '"{{#boolean}}This should not be rendered.{{/boolean}}"'
expected: '""'
- name: Null is falsey
desc: Null is falsey.
data: { "null": null }
template: '"{{#null}}This should not be rendered.{{/null}}"'
expected: '""'
- name: Context
desc: Objects and hashes should be pushed onto the context stack.
data: { context: { name: 'Joe' } }
template: '"{{#context}}Hi {{name}}.{{/context}}"'
expected: '"Hi Joe."'
- name: Parent contexts
desc: Names missing in the current context are looked up in the stack.
data: { a: "foo", b: "wrong", sec: { b: "bar" }, c : { d : "baz" } }
template: '"{{#sec}}{{a}}, {{b}}, {{c.d}}{{/sec}}"'
expected: '"foo, bar, baz"'
- name: Variable test
desc: |
Non-false sections have their value at the top of context,
accessible as {{.}} or through the parent context. This gives
a simple way to display content conditionally if a variable exists.
data: { foo: "bar" }
template: '"{{#foo}}{{.}} is {{foo}}{{/foo}}"'
expected: '"bar is bar"'
- name: List Contexts
desc: All elements on the context stack should be accessible within lists.
data:
tops:
- tname:
upper: "A"
lower: "a"
middles:
- mname: "1"
bottoms:
- bname: "x"
- bname: "y"
template: '{{#tops}}{{#middles}}{{tname.lower}}{{mname}}.{{#bottoms}}{{tname.upper}}{{mname}}{{bname}}.{{/bottoms}}{{/middles}}{{/tops}}'
expected: 'a1.A1x.A1y.'
- name: Deeply Nested Contexts
desc: All elements on the context stack should be accessible.
data:
a: { one: 1 }
b: { two: 2 }
c: { three: 3, d : { four : 4, five : 5 } }
template: |
{{#a}}
{{one}}
{{#b}}
{{one}}{{two}}{{one}}
{{#c}}
{{one}}{{two}}{{three}}{{two}}{{one}}
{{#d}}
{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}
{{#five}}
{{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}}
{{one}}{{two}}{{three}}{{four}}{{.}}6{{.}}{{four}}{{three}}{{two}}{{one}}
{{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}}
{{/five}}
{{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}}
{{/d}}
{{one}}{{two}}{{three}}{{two}}{{one}}
{{/c}}
{{one}}{{two}}{{one}}
{{/b}}
{{one}}
{{/a}}
expected: |
1
121
12321
1234321
123454321
12345654321
123454321
1234321
12321
121
1
- name: List
desc: Lists should be iterated; list items should visit the context stack.
data: { list: [ { item: 1 }, { item: 2 }, { item: 3 } ] }
template: '"{{#list}}{{item}}{{/list}}"'
expected: '"123"'
- name: Empty List
desc: Empty lists should behave like falsey values.
data: { list: [ ] }
template: '"{{#list}}Yay lists!{{/list}}"'
expected: '""'
- name: Doubled
desc: Multiple sections per template should be permitted.
data: { bool: true, two: 'second' }
template: |
{{#bool}}
* first
{{/bool}}
* {{two}}
{{#bool}}
* third
{{/bool}}
expected: |
* first
* second
* third
- name: Nested (Truthy)
desc: Nested truthy sections should have their contents rendered.
data: { bool: true }
template: "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |"
expected: "| A B C D E |"
- name: Nested (Falsey)
desc: Nested falsey sections should be omitted.
data: { bool: false }
template: "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |"
expected: "| A E |"
- name: Context Misses
desc: Failed context lookups should be considered falsey.
data: { }
template: "[{{#missing}}Found key 'missing'!{{/missing}}]"
expected: "[]"
# Implicit Iterators
- name: Implicit Iterator - String
desc: Implicit iterators should directly interpolate strings.
data:
list: [ 'a', 'b', 'c', 'd', 'e' ]
template: '"{{#list}}({{.}}){{/list}}"'
expected: '"(a)(b)(c)(d)(e)"'
- name: Implicit Iterator - Integer
desc: Implicit iterators should cast integers to strings and interpolate.
data:
list: [ 1, 2, 3, 4, 5 ]
template: '"{{#list}}({{.}}){{/list}}"'
expected: '"(1)(2)(3)(4)(5)"'
- name: Implicit Iterator - Decimal
desc: Implicit iterators should cast decimals to strings and interpolate.
data:
list: [ 1.10, 2.20, 3.30, 4.40, 5.50 ]
template: '"{{#list}}({{.}}){{/list}}"'
expected: '"(1.1)(2.2)(3.3)(4.4)(5.5)"'
- name: Implicit Iterator - Array
desc: Implicit iterators should allow iterating over nested arrays.
data:
list: [ [1, 2, 3], ['a', 'b', 'c'] ]
template: '"{{#list}}({{#.}}{{.}}{{/.}}){{/list}}"'
expected: '"(123)(abc)"'
# Dotted Names
- name: Dotted Names - Truthy
desc: Dotted names should be valid for Section tags.
data: { a: { b: { c: true } } }
template: '"{{#a.b.c}}Here{{/a.b.c}}" == "Here"'
expected: '"Here" == "Here"'
- name: Dotted Names - Falsey
desc: Dotted names should be valid for Section tags.
data: { a: { b: { c: false } } }
template: '"{{#a.b.c}}Here{{/a.b.c}}" == ""'
expected: '"" == ""'
- name: Dotted Names - Broken Chains
desc: Dotted names that cannot be resolved should be considered falsey.
data: { a: { } }
template: '"{{#a.b.c}}Here{{/a.b.c}}" == ""'
expected: '"" == ""'
# Whitespace Sensitivity
- name: Surrounding Whitespace
desc: Sections should not alter surrounding whitespace.
data: { boolean: true }
template: " | {{#boolean}}\t|\t{{/boolean}} | \n"
expected: " | \t|\t | \n"
- name: Internal Whitespace
desc: Sections should not alter internal whitespace.
data: { boolean: true }
template: " | {{#boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n"
expected: " | \n | \n"
- name: Indented Inline Sections
desc: Single-line sections should not alter surrounding whitespace.
data: { boolean: true }
template: " {{#boolean}}YES{{/boolean}}\n {{#boolean}}GOOD{{/boolean}}\n"
expected: " YES\n GOOD\n"
- name: Standalone Lines
desc: Standalone lines should be removed from the template.
data: { boolean: true }
template: |
| This Is
{{#boolean}}
|
{{/boolean}}
| A Line
expected: |
| This Is
|
| A Line
- name: Indented Standalone Lines
desc: Indented standalone lines should be removed from the template.
data: { boolean: true }
template: |
| This Is
{{#boolean}}
|
{{/boolean}}
| A Line
expected: |
| This Is
|
| A Line
- name: Standalone Line Endings
desc: '"\r\n" should be considered a newline for standalone tags.'
data: { boolean: true }
template: "|\r\n{{#boolean}}\r\n{{/boolean}}\r\n|"
expected: "|\r\n|"
- name: Standalone Without Previous Line
desc: Standalone tags should not require a newline to precede them.
data: { boolean: true }
template: " {{#boolean}}\n#{{/boolean}}\n/"
expected: "#\n/"
- name: Standalone Without Newline
desc: Standalone tags should not require a newline to follow them.
data: { boolean: true }
template: "#{{#boolean}}\n/\n {{/boolean}}"
expected: "#\n/\n"
# Whitespace Insensitivity
- name: Padding
desc: Superfluous in-tag whitespace should be ignored.
data: { boolean: true }
template: '|{{# boolean }}={{/ boolean }}|'
expected: '|=|'

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,377 @@
overview: |
Rationale: this special notation was introduced primarily to allow the dynamic
loading of partials. The main advantage that this notation offers is to allow
dynamic loading of partials, which is particularly useful in cases where
polymorphic data needs to be rendered in different ways. Such cases would
otherwise be possible to render only with solutions that are convoluted,
inefficient, or both.
Example.
Let's consider the following data:
items: [
{ content: 'Hello, World!' },
{ url: 'http://example.com/foo.jpg' },
{ content: 'Some text' },
{ content: 'Some other text' },
{ url: 'http://example.com/bar.jpg' },
{ url: 'http://example.com/baz.jpg' },
{ content: 'Last text here' }
]
The goal is to render the different types of items in different ways. The
items having a key named `content` should be rendered with the template
`text.mustache`:
{{!text.mustache}}
{{content}}
And the items having a key named `url` should be rendered with the template
`image.mustache`:
{{!image.mustache}}
<img src="{{url}}"/>
There are already several ways to achieve this goal, here below are
illustrated and discussed the most significant solutions to this problem.
Using Pre-Processing
The idea is to use a secondary templating mechanism to dynamically generate
the template that will be rendered.
The template that our secondary templating mechanism generates might look
like this:
{{!template.mustache}}
{{items.1.content}}
<img src="{{items.2.url}}"/>
{{items.3.content}}
{{items.4.content}}
<img src="{{items.5.url}}"/>
<img src="{{items.6.url}}"/>
{{items.7.content}}
This solutions offers the advantages of having more control over the template
and minimizing the template blocks to the essential ones.
The drawbacks are the rendering speed and the complexity that the secondary
templating mechanism requires.
Using Lambdas
The idea is to inject functions into the data that will be later called from
the template.
This way the data will look like this:
items: [
{
content: 'Hello, World!',
html: function() { return '{{>text}}'; }
},
{
url: 'http://example.com/foo.jpg',
html: function() { return '{{>image}}'; }
},
{
content: 'Some text',
html: function() { return '{{>text}}'; }
},
{
content: 'Some other text',
html: function() { return '{{>text}}'; }
},
{
url: 'http://example.com/bar.jpg',
html: function() { return '{{>image}}'; }
},
{
url: 'http://example.com/baz.jpg',
html: function() { return '{{>image}}'; }
},
{
content: 'Last text here',
html: function() { return '{{>text}}'; }
}
]
And the template will look like this:
{{!template.mustache}}
{{#items}}
{{{html}}}
{{/items}}
The advantage this solution offers is to have a light main template.
The drawback is that the data needs to embed logic and template tags in
it.
Using If-Else Blocks
The idea is to put some logic into the main template so it can select the
templates at rendering time:
{{!template.mustache}}
{{#items}}
{{#url}}
{{>image}}
{{/url}}
{{#content}}
{{>text}}
{{/content}}
{{/items}}
The main advantage of this solution is that it works without adding any
overhead fields to the data. It also documents which external templates are
appropriate for expansion in this position.
The drawback is that this solution isn't optimal for heterogeneous data sets
as the main template grows linearly with the number of polymorphic variants.
Using Dynamic Names
This is the solution proposed by this spec.
The idea is to load partials dynamically.
This way the data items have to be tagged with the corresponding partial name:
items: [
{ content: 'Hello, World!', dynamic: 'text' },
{ url: 'http://example.com/foo.jpg', dynamic: 'image' },
{ content: 'Some text', dynamic: 'text' },
{ content: 'Some other text', dynamic: 'text' },
{ url: 'http://example.com/bar.jpg', dynamic: 'image' },
{ url: 'http://example.com/baz.jpg', dynamic: 'image' },
{ content: 'Last text here', dynamic: 'text' }
]
And the template would simple look like this:
{{!template.mustache}}
{{#items}}
{{>*dynamic}}
{{/items}}
Summary:
+----------------+---------------------+-----------------------------------+
| Approach | Pros | Cons |
+----------------+---------------------+-----------------------------------+
| Pre-Processing | Essential template, | Secondary templating system |
| | more control | needed, slower rendering |
| Lambdas | Slim template | Data tagging, logic in data |
| If Blocks | No data overhead, | Template linear growth |
| | self-documenting | |
| Dynamic Names | Slim template | Data tagging |
+----------------+---------------------+-----------------------------------+
Dynamic Names are a special notation to dynamically determine a tag's content.
Dynamic Names MUST be a non-whitespace character sequence NOT containing
the current closing delimiter. A Dynamic Name consists of an asterisk,
followed by a dotted name. The dotted name follows the same notation as in an
Interpolation tag.
This tag's dotted name, which is the Dynamic Name excluding the
leading asterisk, references a key in the context whose value will be used in
place of the Dynamic Name itself as content of the tag. The dotted name
resolution produces the same value as an Interpolation tag and does not affect
the context for further processing.
Set Delimiter tags MUST NOT affect the resolution of a Dynamic Name. The
Dynamic Names MUST be resolved against the context stack local to the tag.
Failed resolution of the dynamic name SHOULD result in nothing being rendered.
Engines that implement Dynamic Names MUST support their use in Partial tags.
In engines that also implement the optional inheritance spec, Dynamic Names
inside Parent tags SHOULD be supported as well. Dynamic Names cannot be
resolved more than once (Dynamic Names cannot be nested).
tests:
- name: Basic Behavior - Partial
desc: The asterisk operator is used for dynamic partials.
data: { dynamic: 'content' }
template: '"{{>*dynamic}}"'
partials: { content: 'Hello, world!' }
expected: '"Hello, world!"'
- name: Basic Behavior - Name Resolution
desc: |
The asterisk is not part of the name that will be resolved in the context.
data: { dynamic: 'content', '*dynamic': 'wrong' }
template: '"{{>*dynamic}}"'
partials: { content: 'Hello, world!', wrong: 'Invisible' }
expected: '"Hello, world!"'
- name: Context Misses - Partial
desc: Failed context lookups should be considered falsey.
data: { }
template: '"{{>*missing}}"'
partials: { missing: 'Hello, world!' }
expected: '""'
- name: Failed Lookup - Partial
desc: The empty string should be used when the named partial is not found.
data: { dynamic: 'content' }
template: '"{{>*dynamic}}"'
partials: { foobar: 'Hello, world!' }
expected: '""'
- name: Context
desc: The dynamic partial should operate within the current context.
data: { text: 'Hello, world!', example: 'partial' }
template: '"{{>*example}}"'
partials: { partial: '*{{text}}*' }
expected: '"*Hello, world!*"'
- name: Dotted Names
desc: The dynamic partial should operate within the current context.
data: { text: 'Hello, world!', foo: { bar: { baz: 'partial' } } }
template: '"{{>*foo.bar.baz}}"'
partials: { partial: '*{{text}}*' }
expected: '"*Hello, world!*"'
- name: Dotted Names - Operator Precedence
desc: The dotted name should be resolved entirely before being dereferenced.
data:
text: 'Hello, world!'
foo: 'test'
test:
bar:
baz: 'partial'
template: '"{{>*foo.bar.baz}}"'
partials: { partial: '*{{text}}*' }
expected: '""'
- name: Dotted Names - Failed Lookup
desc: The dynamic partial should operate within the current context.
data:
foo:
text: 'Hello, world!'
bar:
baz: 'partial'
template: '"{{>*foo.bar.baz}}"'
partials: { partial: '*{{text}}*' }
expected: '"**"'
- name: Dotted names - Context Stacking
desc: Dotted names should not push a new frame on the context stack.
data:
section1: { value: 'section1' }
section2: { dynamic: 'partial', value: 'section2' }
template: "{{#section1}}{{>*section2.dynamic}}{{/section1}}"
partials:
partial: '"{{value}}"'
expected: '"section1"'
- name: Dotted names - Context Stacking Under Repetition
desc: Dotted names should not push a new frame on the context stack.
data:
value: 'test'
section1: [ 1, 2 ]
section2: { dynamic: 'partial', value: 'section2' }
template: "{{#section1}}{{>*section2.dynamic}}{{/section1}}"
partials:
partial: "{{value}}"
expected: "testtest"
- name: Dotted names - Context Stacking Failed Lookup
desc: Dotted names should resolve against the proper context stack.
data:
section1: [ 1, 2 ]
section2: { dynamic: 'partial', value: 'section2' }
template: "{{#section1}}{{>*section2.dynamic}}{{/section1}}"
partials:
partial: '"{{value}}"'
expected: '""""'
- name: Recursion
desc: Dynamic partials should properly recurse.
data:
template: 'node'
content: 'X'
nodes: [ { content: 'Y', nodes: [] } ]
template: '{{>*template}}'
partials: { node: '{{content}}<{{#nodes}}{{>*template}}{{/nodes}}>' }
expected: 'X<Y<>>'
- name: Dynamic Names - Double Dereferencing
desc: Dynamic Names can't be dereferenced more than once.
data: { dynamic: 'test', 'test': 'content' }
template: '"{{>**dynamic}}"'
partials: { content: 'Hello, world!' }
expected: '""'
- name: Dynamic Names - Composed Dereferencing
desc: Dotted Names are resolved entirely before dereferencing begins.
data: { foo: 'fizz', bar: 'buzz', fizz: { buzz: { content: null } } }
template: '"{{>*foo.*bar}}"'
partials: { content: 'Hello, world!' }
expected: '""'
# Whitespace Sensitivity
- name: Surrounding Whitespace
desc: |
A dynamic partial should not alter surrounding whitespace; any
whitespace preceding the tag should be treated as indentation while any
whitespace succeding the tag should be left untouched.
data: { partial: 'foobar' }
template: '| {{>*partial}} |'
partials: { foobar: "\t|\t" }
expected: "| \t|\t |"
- name: Inline Indentation
desc: |
Whitespace should be left untouched: whitespaces preceding the tag
should be treated as indentation.
data: { dynamic: 'partial', data: '|' }
template: " {{data}} {{>*dynamic}}\n"
partials: { partial: ">\n>" }
expected: " | >\n>\n"
- name: Standalone Line Endings
desc: '"\r\n" should be considered a newline for standalone tags.'
data: { dynamic: 'partial' }
template: "|\r\n{{>*dynamic}}\r\n|"
partials: { partial: ">" }
expected: "|\r\n>|"
- name: Standalone Without Previous Line
desc: Standalone tags should not require a newline to precede them.
data: { dynamic: 'partial' }
template: " {{>*dynamic}}\n>"
partials: { partial: ">\n>"}
expected: " >\n >>"
- name: Standalone Without Newline
desc: Standalone tags should not require a newline to follow them.
data: { dynamic: 'partial' }
template: ">\n {{>*dynamic}}"
partials: { partial: ">\n>" }
expected: ">\n >\n >"
- name: Standalone Indentation
desc: Each line of the partial should be indented before rendering.
data: { dynamic: 'partial', content: "<\n->" }
template: |
\
{{>*dynamic}}
/
partials:
partial: |
|
{{{content}}}
|
expected: |
\
|
<
->
|
/
# Whitespace Insensitivity
- name: Padding Whitespace
desc: Superfluous in-tag whitespace should be ignored.
data: { dynamic: 'partial', boolean: true }
template: "|{{> * dynamic }}|"
partials: { partial: "[]" }
expected: '|[]|'

View File

@ -0,0 +1,250 @@
{
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
"overview": "Like partials, Parent tags are used to expand an external template into the\ncurrent template. Unlike partials, Parent tags may contain optional\narguments delimited by Block tags. For this reason, Parent tags may also be\nreferred to as Parametric Partials.\n\nThe Parent tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter; each Parent tag MUST be followed by\nan End Section tag with the same content within the matching Parent tag.\n\nThis tag's content names the Parent template to inject. Set Delimiter tags\nPreceding a Parent tag MUST NOT affect the parsing of the injected external\ntemplate. The Parent MUST be rendered against the context stack local to the\ntag. If the named Parent cannot be found, the empty string SHOULD be used\ninstead, as in interpolations.\n\nParent tags SHOULD be treated as standalone when appropriate. If this tag is\nused standalone, any whitespace preceding the tag should be treated as\nindentation, and prepended to each line of the Parent before rendering.\n\nThe Block tags' content MUST be a non-whitespace character sequence NOT\ncontaining the current closing delimiter. Each Block tag MUST be followed by\nan End Section tag with the same content within the matching Block tag. This\ntag's content determines the parameter or argument name.\n\nBlock tags may appear both inside and outside of Parent tags. In both cases,\nthey specify a position within the template that can be overridden; it is a\nparameter of the containing template. The template text between the Block tag\nand its matching End Section tag defines the default content to render when\nthe parameter is not overridden from outside.\n\nIn addition, when used inside of a Parent tag, the template text between a\nBlock tag and its matching End Section tag defines content that replaces the\ndefault defined in the Parent template. This content is the argument passed\nto the Parent template.\n\nThe practice of injecting an external template using a Parent tag is referred\nto as inheritance. If the Parent tag includes a Block tag that overrides a\nparameter of the Parent template, this may also be referred to as\nsubstitution.\n\nParent templates are taken from the same namespace as regular Partial\ntemplates and in fact, injecting a regular Partial is exactly equivalent to\ninjecting a Parent without making any substitutions. Parameter and arguments\nnames live in a namespace that is distinct from both Partials and the context.\n",
"tests": [
{
"name": "Default",
"desc": "Default content should be rendered if the block isn't overridden",
"data": {
},
"template": "{{$title}}Default title{{/title}}\n",
"expected": "Default title\n"
},
{
"name": "Variable",
"desc": "Default content renders variables",
"data": {
"bar": "baz"
},
"template": "{{$foo}}default {{bar}} content{{/foo}}\n",
"expected": "default baz content\n"
},
{
"name": "Triple Mustache",
"desc": "Default content renders triple mustache variables",
"data": {
"bar": "<baz>"
},
"template": "{{$foo}}default {{{bar}}} content{{/foo}}\n",
"expected": "default <baz> content\n"
},
{
"name": "Sections",
"desc": "Default content renders sections",
"data": {
"bar": {
"baz": "qux"
}
},
"template": "{{$foo}}default {{#bar}}{{baz}}{{/bar}} content{{/foo}}\n",
"expected": "default qux content\n"
},
{
"name": "Negative Sections",
"desc": "Default content renders negative sections",
"data": {
"baz": "three"
},
"template": "{{$foo}}default {{^bar}}{{baz}}{{/bar}} content{{/foo}}\n",
"expected": "default three content\n"
},
{
"name": "Mustache Injection",
"desc": "Mustache injection in default content",
"data": {
"bar": {
"baz": "{{qux}}"
}
},
"template": "{{$foo}}default {{#bar}}{{baz}}{{/bar}} content{{/foo}}\n",
"expected": "default {{qux}} content\n"
},
{
"name": "Inherit",
"desc": "Default content rendered inside inherited templates",
"data": {
},
"template": "{{<include}}{{/include}}\n",
"partials": {
"include": "{{$foo}}default content{{/foo}}"
},
"expected": "default content"
},
{
"name": "Overridden content",
"desc": "Overridden content",
"data": {
},
"template": "{{<super}}{{$title}}sub template title{{/title}}{{/super}}",
"partials": {
"super": "...{{$title}}Default title{{/title}}..."
},
"expected": "...sub template title..."
},
{
"name": "Data does not override block",
"desc": "Context does not override argument passed into parent",
"data": {
"var": "var in data"
},
"template": "{{<include}}{{$var}}var in template{{/var}}{{/include}}",
"partials": {
"include": "{{$var}}var in include{{/var}}"
},
"expected": "var in template"
},
{
"name": "Data does not override block default",
"desc": "Context does not override default content of block",
"data": {
"var": "var in data"
},
"template": "{{<include}}{{/include}}",
"partials": {
"include": "{{$var}}var in include{{/var}}"
},
"expected": "var in include"
},
{
"name": "Overridden parent",
"desc": "Overridden parent",
"data": {
},
"template": "test {{<parent}}{{$stuff}}override{{/stuff}}{{/parent}}",
"partials": {
"parent": "{{$stuff}}...{{/stuff}}"
},
"expected": "test override"
},
{
"name": "Two overridden parents",
"desc": "Two overridden parents with different content",
"data": {
},
"template": "test {{<parent}}{{$stuff}}override1{{/stuff}}{{/parent}} {{<parent}}{{$stuff}}override2{{/stuff}}{{/parent}}\n",
"partials": {
"parent": "|{{$stuff}}...{{/stuff}}{{$default}} default{{/default}}|"
},
"expected": "test |override1 default| |override2 default|\n"
},
{
"name": "Override parent with newlines",
"desc": "Override parent with newlines",
"data": {
},
"template": "{{<parent}}{{$ballmer}}\npeaked\n\n:(\n{{/ballmer}}{{/parent}}",
"partials": {
"parent": "{{$ballmer}}peaking{{/ballmer}}"
},
"expected": "peaked\n\n:(\n"
},
{
"name": "Inherit indentation",
"desc": "Inherit indentation when overriding a parent",
"data": {
},
"template": "{{<parent}}{{$nineties}}hammer time{{/nineties}}{{/parent}}",
"partials": {
"parent": "stop:\n {{$nineties}}collaborate and listen{{/nineties}}\n"
},
"expected": "stop:\n hammer time\n"
},
{
"name": "Only one override",
"desc": "Override one parameter but not the other",
"data": {
},
"template": "{{<parent}}{{$stuff2}}override two{{/stuff2}}{{/parent}}",
"partials": {
"parent": "{{$stuff}}new default one{{/stuff}}, {{$stuff2}}new default two{{/stuff2}}"
},
"expected": "new default one, override two"
},
{
"name": "Parent template",
"desc": "Parent templates behave identically to partials when called with no parameters",
"data": {
},
"template": "{{>parent}}|{{<parent}}{{/parent}}",
"partials": {
"parent": "{{$foo}}default content{{/foo}}"
},
"expected": "default content|default content"
},
{
"name": "Recursion",
"desc": "Recursion in inherited templates",
"data": {
},
"template": "{{<parent}}{{$foo}}override{{/foo}}{{/parent}}",
"partials": {
"parent": "{{$foo}}default content{{/foo}} {{$bar}}{{<parent2}}{{/parent2}}{{/bar}}",
"parent2": "{{$foo}}parent2 default content{{/foo}} {{<parent}}{{$bar}}don't recurse{{/bar}}{{/parent}}"
},
"expected": "override override override don't recurse"
},
{
"name": "Multi-level inheritance",
"desc": "Top-level substitutions take precedence in multi-level inheritance",
"data": {
},
"template": "{{<parent}}{{$a}}c{{/a}}{{/parent}}",
"partials": {
"parent": "{{<older}}{{$a}}p{{/a}}{{/older}}",
"older": "{{<grandParent}}{{$a}}o{{/a}}{{/grandParent}}",
"grandParent": "{{$a}}g{{/a}}"
},
"expected": "c"
},
{
"name": "Multi-level inheritance, no sub child",
"desc": "Top-level substitutions take precedence in multi-level inheritance",
"data": {
},
"template": "{{<parent}}{{/parent}}",
"partials": {
"parent": "{{<older}}{{$a}}p{{/a}}{{/older}}",
"older": "{{<grandParent}}{{$a}}o{{/a}}{{/grandParent}}",
"grandParent": "{{$a}}g{{/a}}"
},
"expected": "p"
},
{
"name": "Text inside parent",
"desc": "Ignores text inside parent templates, but does parse $ tags",
"data": {
},
"template": "{{<parent}} asdfasd {{$foo}}hmm{{/foo}} asdfasdfasdf {{/parent}}",
"partials": {
"parent": "{{$foo}}default content{{/foo}}"
},
"expected": "hmm"
},
{
"name": "Text inside parent",
"desc": "Allows text inside a parent tag, but ignores it",
"data": {
},
"template": "{{<parent}} asdfasd asdfasdfasdf {{/parent}}",
"partials": {
"parent": "{{$foo}}default content{{/foo}}"
},
"expected": "default content"
},
{
"name": "Block scope",
"desc": "Scope of a substituted block is evaluated in the context of the parent template",
"data": {
"fruit": "apples",
"nested": {
"fruit": "bananas"
}
},
"template": "{{<parent}}{{$block}}I say {{fruit}}.{{/block}}{{/parent}}",
"partials": {
"parent": "{{#nested}}{{$block}}You say {{fruit}}.{{/block}}{{/nested}}"
},
"expected": "I say bananas."
}
]
}

View File

@ -0,0 +1,237 @@
overview: |
Like partials, Parent tags are used to expand an external template into the
current template. Unlike partials, Parent tags may contain optional
arguments delimited by Block tags. For this reason, Parent tags may also be
referred to as Parametric Partials.
The Parent tags' content MUST be a non-whitespace character sequence NOT
containing the current closing delimiter; each Parent tag MUST be followed by
an End Section tag with the same content within the matching Parent tag.
This tag's content names the Parent template to inject. Set Delimiter tags
Preceding a Parent tag MUST NOT affect the parsing of the injected external
template. The Parent MUST be rendered against the context stack local to the
tag. If the named Parent cannot be found, the empty string SHOULD be used
instead, as in interpolations.
Parent tags SHOULD be treated as standalone when appropriate. If this tag is
used standalone, any whitespace preceding the tag should be treated as
indentation, and prepended to each line of the Parent before rendering.
The Block tags' content MUST be a non-whitespace character sequence NOT
containing the current closing delimiter. Each Block tag MUST be followed by
an End Section tag with the same content within the matching Block tag. This
tag's content determines the parameter or argument name.
Block tags may appear both inside and outside of Parent tags. In both cases,
they specify a position within the template that can be overridden; it is a
parameter of the containing template. The template text between the Block tag
and its matching End Section tag defines the default content to render when
the parameter is not overridden from outside.
In addition, when used inside of a Parent tag, the template text between a
Block tag and its matching End Section tag defines content that replaces the
default defined in the Parent template. This content is the argument passed
to the Parent template.
The practice of injecting an external template using a Parent tag is referred
to as inheritance. If the Parent tag includes a Block tag that overrides a
parameter of the Parent template, this may also be referred to as
substitution.
Parent templates are taken from the same namespace as regular Partial
templates and in fact, injecting a regular Partial is exactly equivalent to
injecting a Parent without making any substitutions. Parameter and arguments
names live in a namespace that is distinct from both Partials and the context.
tests:
- name: Default
desc: Default content should be rendered if the block isn't overridden
data: { }
template: |
{{$title}}Default title{{/title}}
expected: |
Default title
- name: Variable
desc: Default content renders variables
data: { bar: 'baz' }
template: |
{{$foo}}default {{bar}} content{{/foo}}
expected: |
default baz content
- name: Triple Mustache
desc: Default content renders triple mustache variables
data: { bar: '<baz>' }
template: |
{{$foo}}default {{{bar}}} content{{/foo}}
expected: |
default <baz> content
- name: Sections
desc: Default content renders sections
data: { bar: {baz: 'qux'} }
template: |
{{$foo}}default {{#bar}}{{baz}}{{/bar}} content{{/foo}}
expected: |
default qux content
- name: Negative Sections
desc: Default content renders negative sections
data: { baz: 'three' }
template: |
{{$foo}}default {{^bar}}{{baz}}{{/bar}} content{{/foo}}
expected: |
default three content
- name: Mustache Injection
desc: Mustache injection in default content
data: {bar: {baz: '{{qux}}'} }
template: |
{{$foo}}default {{#bar}}{{baz}}{{/bar}} content{{/foo}}
expected: |
default {{qux}} content
- name: Inherit
desc: Default content rendered inside inherited templates
data: { }
template: |
{{<include}}{{/include}}
partials:
include: "{{$foo}}default content{{/foo}}"
expected: "default content"
- name: Overridden content
desc: Overridden content
data: { }
template: "{{<super}}{{$title}}sub template title{{/title}}{{/super}}"
partials:
super: "...{{$title}}Default title{{/title}}..."
expected: "...sub template title..."
- name: Data does not override block
desc: Context does not override argument passed into parent
data: { var: 'var in data' }
template: "{{<include}}{{$var}}var in template{{/var}}{{/include}}"
partials:
include: "{{$var}}var in include{{/var}}"
expected: "var in template"
- name: Data does not override block default
desc: Context does not override default content of block
data: { var: 'var in data' }
template: "{{<include}}{{/include}}"
partials:
include: "{{$var}}var in include{{/var}}"
expected: "var in include"
- name: Overridden parent
desc: Overridden parent
data: { }
template: "test {{<parent}}{{$stuff}}override{{/stuff}}{{/parent}}"
partials:
parent: "{{$stuff}}...{{/stuff}}"
expected: "test override"
- name: Two overridden parents
desc: Two overridden parents with different content
data: { }
template: |
test {{<parent}}{{$stuff}}override1{{/stuff}}{{/parent}} {{<parent}}{{$stuff}}override2{{/stuff}}{{/parent}}
partials:
parent: "|{{$stuff}}...{{/stuff}}{{$default}} default{{/default}}|"
expected: |
test |override1 default| |override2 default|
- name: Override parent with newlines
desc: Override parent with newlines
data: { }
template: "{{<parent}}{{$ballmer}}\npeaked\n\n:(\n{{/ballmer}}{{/parent}}"
partials:
parent: "{{$ballmer}}peaking{{/ballmer}}"
expected: "peaked\n\n:(\n"
- name: Inherit indentation
desc: Inherit indentation when overriding a parent
data: { }
template: "{{<parent}}{{$nineties}}hammer time{{/nineties}}{{/parent}}"
partials:
parent: |
stop:
{{$nineties}}collaborate and listen{{/nineties}}
expected: |
stop:
hammer time
- name: Only one override
desc: Override one parameter but not the other
data: { }
template: "{{<parent}}{{$stuff2}}override two{{/stuff2}}{{/parent}}"
partials:
parent: "{{$stuff}}new default one{{/stuff}}, {{$stuff2}}new default two{{/stuff2}}"
expected: "new default one, override two"
- name: Parent template
desc: Parent templates behave identically to partials when called with no parameters
data: { }
template: "{{>parent}}|{{<parent}}{{/parent}}"
partials:
parent: "{{$foo}}default content{{/foo}}"
expected: "default content|default content"
- name: Recursion
desc: Recursion in inherited templates
data: {}
template: "{{<parent}}{{$foo}}override{{/foo}}{{/parent}}"
partials:
parent: "{{$foo}}default content{{/foo}} {{$bar}}{{<parent2}}{{/parent2}}{{/bar}}"
parent2: "{{$foo}}parent2 default content{{/foo}} {{<parent}}{{$bar}}don't recurse{{/bar}}{{/parent}}"
expected: "override override override don't recurse"
- name: Multi-level inheritance
desc: Top-level substitutions take precedence in multi-level inheritance
data: { }
template: "{{<parent}}{{$a}}c{{/a}}{{/parent}}"
partials:
parent: "{{<older}}{{$a}}p{{/a}}{{/older}}"
older: "{{<grandParent}}{{$a}}o{{/a}}{{/grandParent}}"
grandParent: "{{$a}}g{{/a}}"
expected: c
- name: Multi-level inheritance, no sub child
desc: Top-level substitutions take precedence in multi-level inheritance
data: { }
template: "{{<parent}}{{/parent}}"
partials:
parent: "{{<older}}{{$a}}p{{/a}}{{/older}}"
older: "{{<grandParent}}{{$a}}o{{/a}}{{/grandParent}}"
grandParent: "{{$a}}g{{/a}}"
expected: p
- name: Text inside parent
desc: Ignores text inside parent templates, but does parse $ tags
data: { }
template: "{{<parent}} asdfasd {{$foo}}hmm{{/foo}} asdfasdfasdf {{/parent}}"
partials:
parent: "{{$foo}}default content{{/foo}}"
expected:
hmm
- name: Text inside parent
desc: Allows text inside a parent tag, but ignores it
data: {}
template: "{{<parent}} asdfasd asdfasdfasdf {{/parent}}"
partials:
parent: "{{$foo}}default content{{/foo}}"
expected: default content
- name: Block scope
desc: Scope of a substituted block is evaluated in the context of the parent template
data:
fruit: apples
nested:
fruit: bananas
template: "{{<parent}}{{$block}}I say {{fruit}}.{{/block}}{{/parent}}"
partials:
parent: "{{#nested}}{{$block}}You say {{fruit}}.{{/block}}{{/nested}}"
expected: I say bananas.

View File

@ -0,0 +1,212 @@
{
"__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.",
"overview": "Lambdas are a special-cased data type for use in interpolations and\nsections.\n\nWhen used as the data value for an Interpolation tag, the lambda MUST be\ntreatable as an arity 0 function, and invoked as such. The returned value\nMUST be rendered against the default delimiters, then interpolated in place\nof the lambda.\n\nWhen used as the data value for a Section tag, the lambda MUST be treatable\nas an arity 1 function, and invoked as such (passing a String containing the\nunprocessed section contents). The returned value MUST be rendered against\nthe current delimiters, then interpolated in place of the section.\n",
"tests": [
{
"name": "Interpolation",
"desc": "A lambda's return value should be interpolated.",
"data": {
"lambda": {
"__tag__": "code",
"ruby": "proc { \"world\" }",
"raku": "sub { \"world\" }",
"perl": "sub { \"world\" }",
"js": "function() { return \"world\" }",
"php": "return \"world\";",
"python": "lambda: \"world\"",
"clojure": "(fn [] \"world\")",
"lisp": "(lambda () \"world\")",
"pwsh": "\"world\""
}
},
"template": "Hello, {{lambda}}!",
"expected": "Hello, world!"
},
{
"name": "Interpolation - Expansion",
"desc": "A lambda's return value should be parsed.",
"data": {
"planet": "world",
"lambda": {
"__tag__": "code",
"ruby": "proc { \"{{planet}}\" }",
"raku": "sub { q+{{planet}}+ }",
"perl": "sub { \"{{planet}}\" }",
"js": "function() { return \"{{planet}}\" }",
"php": "return \"{{planet}}\";",
"python": "lambda: \"{{planet}}\"",
"clojure": "(fn [] \"{{planet}}\")",
"lisp": "(lambda () \"{{planet}}\")",
"pwsh": "\"{{planet}}\""
}
},
"template": "Hello, {{lambda}}!",
"expected": "Hello, world!"
},
{
"name": "Interpolation - Alternate Delimiters",
"desc": "A lambda's return value should parse with the default delimiters.",
"data": {
"planet": "world",
"lambda": {
"__tag__": "code",
"ruby": "proc { \"|planet| => {{planet}}\" }",
"raku": "sub { q+|planet| => {{planet}}+ }",
"perl": "sub { \"|planet| => {{planet}}\" }",
"js": "function() { return \"|planet| => {{planet}}\" }",
"php": "return \"|planet| => {{planet}}\";",
"python": "lambda: \"|planet| => {{planet}}\"",
"clojure": "(fn [] \"|planet| => {{planet}}\")",
"lisp": "(lambda () \"|planet| => {{planet}}\")",
"pwsh": "\"|planet| => {{planet}}\""
}
},
"template": "{{= | | =}}\nHello, (|&lambda|)!",
"expected": "Hello, (|planet| => world)!"
},
{
"name": "Interpolation - Multiple Calls",
"desc": "Interpolated lambdas should not be cached.",
"data": {
"lambda": {
"__tag__": "code",
"ruby": "proc { $calls ||= 0; $calls += 1 }",
"raku": "sub { state $calls += 1 }",
"perl": "sub { no strict; $calls += 1 }",
"js": "function() { return (g=(function(){return this})()).calls=(g.calls||0)+1 }",
"php": "global $calls; return ++$calls;",
"python": "lambda: globals().update(calls=globals().get(\"calls\",0)+1) or calls",
"clojure": "(def g (atom 0)) (fn [] (swap! g inc))",
"lisp": "(let ((g 0)) (lambda () (incf g)))",
"pwsh": "if (($null -eq $script:calls) -or ($script:calls -ge 3)){$script:calls=0}; ++$script:calls; $script:calls"
}
},
"template": "{{lambda}} == {{{lambda}}} == {{lambda}}",
"expected": "1 == 2 == 3"
},
{
"name": "Escaping",
"desc": "Lambda results should be appropriately escaped.",
"data": {
"lambda": {
"__tag__": "code",
"ruby": "proc { \">\" }",
"raku": "sub { \">\" }",
"perl": "sub { \">\" }",
"js": "function() { return \">\" }",
"php": "return \">\";",
"python": "lambda: \">\"",
"clojure": "(fn [] \">\")",
"lisp": "(lambda () \">\")",
"pwsh": "\">\""
}
},
"template": "<{{lambda}}{{{lambda}}}",
"expected": "<&gt;>"
},
{
"name": "Section",
"desc": "Lambdas used for sections should receive the raw section string.",
"data": {
"x": "Error!",
"lambda": {
"__tag__": "code",
"ruby": "proc { |text| text == \"{{x}}\" ? \"yes\" : \"no\" }",
"raku": "sub { $^section eq q+{{x}}+ ?? \"yes\" !! \"no\" }",
"perl": "sub { $_[0] eq \"{{x}}\" ? \"yes\" : \"no\" }",
"js": "function(txt) { return (txt == \"{{x}}\" ? \"yes\" : \"no\") }",
"php": "return ($text == \"{{x}}\") ? \"yes\" : \"no\";",
"python": "lambda text: text == \"{{x}}\" and \"yes\" or \"no\"",
"clojure": "(fn [text] (if (= text \"{{x}}\") \"yes\" \"no\"))",
"lisp": "(lambda (text) (if (string= text \"{{x}}\") \"yes\" \"no\"))",
"pwsh": "if ($args[0] -eq \"{{x}}\") {\"yes\"} else {\"no\"}"
}
},
"template": "<{{#lambda}}{{x}}{{/lambda}}>",
"expected": "<yes>"
},
{
"name": "Section - Expansion",
"desc": "Lambdas used for sections should have their results parsed.",
"data": {
"planet": "Earth",
"lambda": {
"__tag__": "code",
"ruby": "proc { |text| \"#{text}{{planet}}#{text}\" }",
"raku": "sub { $^section ~ q+{{planet}}+ ~ $^section }",
"perl": "sub { $_[0] . \"{{planet}}\" . $_[0] }",
"js": "function(txt) { return txt + \"{{planet}}\" + txt }",
"php": "return $text . \"{{planet}}\" . $text;",
"python": "lambda text: \"%s{{planet}}%s\" % (text, text)",
"clojure": "(fn [text] (str text \"{{planet}}\" text))",
"lisp": "(lambda (text) (format nil \"~a{{planet}}~a\" text text))",
"pwsh": "\"$($args[0]){{planet}}$($args[0])\""
}
},
"template": "<{{#lambda}}-{{/lambda}}>",
"expected": "<-Earth->"
},
{
"name": "Section - Alternate Delimiters",
"desc": "Lambdas used for sections should parse with the current delimiters.",
"data": {
"planet": "Earth",
"lambda": {
"__tag__": "code",
"ruby": "proc { |text| \"#{text}{{planet}} => |planet|#{text}\" }",
"raku": "sub { $^section ~ q+{{planet}} => |planet|+ ~ $^section }",
"perl": "sub { $_[0] . \"{{planet}} => |planet|\" . $_[0] }",
"js": "function(txt) { return txt + \"{{planet}} => |planet|\" + txt }",
"php": "return $text . \"{{planet}} => |planet|\" . $text;",
"python": "lambda text: \"%s{{planet}} => |planet|%s\" % (text, text)",
"clojure": "(fn [text] (str text \"{{planet}} => |planet|\" text))",
"lisp": "(lambda (text) (format nil \"~a{{planet}} => |planet|~a\" text text))",
"pwsh": "\"$($args[0]){{planet}} => |planet|$($args[0])\""
}
},
"template": "{{= | | =}}<|#lambda|-|/lambda|>",
"expected": "<-{{planet}} => Earth->"
},
{
"name": "Section - Multiple Calls",
"desc": "Lambdas used for sections should not be cached.",
"data": {
"lambda": {
"__tag__": "code",
"ruby": "proc { |text| \"__#{text}__\" }",
"raku": "sub { \"__\" ~ $^section ~ \"__\" }",
"perl": "sub { \"__\" . $_[0] . \"__\" }",
"js": "function(txt) { return \"__\" + txt + \"__\" }",
"php": "return \"__\" . $text . \"__\";",
"python": "lambda text: \"__%s__\" % (text)",
"clojure": "(fn [text] (str \"__\" text \"__\"))",
"lisp": "(lambda (text) (format nil \"__~a__\" text))",
"pwsh": "\"__$($args[0])__\""
}
},
"template": "{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}",
"expected": "__FILE__ != __LINE__"
},
{
"name": "Inverted Section",
"desc": "Lambdas used for inverted sections should be considered truthy.",
"data": {
"static": "static",
"lambda": {
"__tag__": "code",
"ruby": "proc { |text| false }",
"raku": "sub { 0 }",
"perl": "sub { 0 }",
"js": "function(txt) { return false }",
"php": "return false;",
"python": "lambda text: 0",
"clojure": "(fn [text] false)",
"lisp": "(lambda (text) (declare (ignore text)) nil)",
"pwsh": "$false"
}
},
"template": "<{{^lambda}}{{static}}{{/lambda}}>",
"expected": "<>"
}
]
}

179
mustache/specs/~lambdas.yml Normal file
View File

@ -0,0 +1,179 @@
overview: |
Lambdas are a special-cased data type for use in interpolations and
sections.
When used as the data value for an Interpolation tag, the lambda MUST be
treatable as an arity 0 function, and invoked as such. The returned value
MUST be rendered against the default delimiters, then interpolated in place
of the lambda.
When used as the data value for a Section tag, the lambda MUST be treatable
as an arity 1 function, and invoked as such (passing a String containing the
unprocessed section contents). The returned value MUST be rendered against
the current delimiters, then interpolated in place of the section.
tests:
- name: Interpolation
desc: A lambda's return value should be interpolated.
data:
lambda: !code
ruby: 'proc { "world" }'
raku: 'sub { "world" }'
perl: 'sub { "world" }'
js: 'function() { return "world" }'
php: 'return "world";'
python: 'lambda: "world"'
clojure: '(fn [] "world")'
lisp: '(lambda () "world")'
pwsh: '"world"'
template: "Hello, {{lambda}}!"
expected: "Hello, world!"
- name: Interpolation - Expansion
desc: A lambda's return value should be parsed.
data:
planet: "world"
lambda: !code
ruby: 'proc { "{{planet}}" }'
raku: 'sub { q+{{planet}}+ }'
perl: 'sub { "{{planet}}" }'
js: 'function() { return "{{planet}}" }'
php: 'return "{{planet}}";'
python: 'lambda: "{{planet}}"'
clojure: '(fn [] "{{planet}}")'
lisp: '(lambda () "{{planet}}")'
pwsh: '"{{planet}}"'
template: "Hello, {{lambda}}!"
expected: "Hello, world!"
- name: Interpolation - Alternate Delimiters
desc: A lambda's return value should parse with the default delimiters.
data:
planet: "world"
lambda: !code
ruby: 'proc { "|planet| => {{planet}}" }'
raku: 'sub { q+|planet| => {{planet}}+ }'
perl: 'sub { "|planet| => {{planet}}" }'
js: 'function() { return "|planet| => {{planet}}" }'
php: 'return "|planet| => {{planet}}";'
python: 'lambda: "|planet| => {{planet}}"'
clojure: '(fn [] "|planet| => {{planet}}")'
lisp: '(lambda () "|planet| => {{planet}}")'
pwsh: '"|planet| => {{planet}}"'
template: "{{= | | =}}\nHello, (|&lambda|)!"
expected: "Hello, (|planet| => world)!"
- name: Interpolation - Multiple Calls
desc: Interpolated lambdas should not be cached.
data:
lambda: !code
ruby: 'proc { $calls ||= 0; $calls += 1 }'
raku: 'sub { state $calls += 1 }'
perl: 'sub { no strict; $calls += 1 }'
js: 'function() { return (g=(function(){return this})()).calls=(g.calls||0)+1 }'
php: 'global $calls; return ++$calls;'
python: 'lambda: globals().update(calls=globals().get("calls",0)+1) or calls'
clojure: '(def g (atom 0)) (fn [] (swap! g inc))'
lisp: '(let ((g 0)) (lambda () (incf g)))'
pwsh: 'if (($null -eq $script:calls) -or ($script:calls -ge 3)){$script:calls=0}; ++$script:calls; $script:calls'
template: '{{lambda}} == {{{lambda}}} == {{lambda}}'
expected: '1 == 2 == 3'
- name: Escaping
desc: Lambda results should be appropriately escaped.
data:
lambda: !code
ruby: 'proc { ">" }'
raku: 'sub { ">" }'
perl: 'sub { ">" }'
js: 'function() { return ">" }'
php: 'return ">";'
python: 'lambda: ">"'
clojure: '(fn [] ">")'
lisp: '(lambda () ">")'
pwsh: '">"'
template: "<{{lambda}}{{{lambda}}}"
expected: "<&gt;>"
- name: Section
desc: Lambdas used for sections should receive the raw section string.
data:
x: 'Error!'
lambda: !code
ruby: 'proc { |text| text == "{{x}}" ? "yes" : "no" }'
raku: 'sub { $^section eq q+{{x}}+ ?? "yes" !! "no" }'
perl: 'sub { $_[0] eq "{{x}}" ? "yes" : "no" }'
js: 'function(txt) { return (txt == "{{x}}" ? "yes" : "no") }'
php: 'return ($text == "{{x}}") ? "yes" : "no";'
python: 'lambda text: text == "{{x}}" and "yes" or "no"'
clojure: '(fn [text] (if (= text "{{x}}") "yes" "no"))'
lisp: '(lambda (text) (if (string= text "{{x}}") "yes" "no"))'
pwsh: 'if ($args[0] -eq "{{x}}") {"yes"} else {"no"}'
template: "<{{#lambda}}{{x}}{{/lambda}}>"
expected: "<yes>"
- name: Section - Expansion
desc: Lambdas used for sections should have their results parsed.
data:
planet: "Earth"
lambda: !code
ruby: 'proc { |text| "#{text}{{planet}}#{text}" }'
raku: 'sub { $^section ~ q+{{planet}}+ ~ $^section }'
perl: 'sub { $_[0] . "{{planet}}" . $_[0] }'
js: 'function(txt) { return txt + "{{planet}}" + txt }'
php: 'return $text . "{{planet}}" . $text;'
python: 'lambda text: "%s{{planet}}%s" % (text, text)'
clojure: '(fn [text] (str text "{{planet}}" text))'
lisp: '(lambda (text) (format nil "~a{{planet}}~a" text text))'
pwsh: '"$($args[0]){{planet}}$($args[0])"'
template: "<{{#lambda}}-{{/lambda}}>"
expected: "<-Earth->"
- name: Section - Alternate Delimiters
desc: Lambdas used for sections should parse with the current delimiters.
data:
planet: "Earth"
lambda: !code
ruby: 'proc { |text| "#{text}{{planet}} => |planet|#{text}" }'
raku: 'sub { $^section ~ q+{{planet}} => |planet|+ ~ $^section }'
perl: 'sub { $_[0] . "{{planet}} => |planet|" . $_[0] }'
js: 'function(txt) { return txt + "{{planet}} => |planet|" + txt }'
php: 'return $text . "{{planet}} => |planet|" . $text;'
python: 'lambda text: "%s{{planet}} => |planet|%s" % (text, text)'
clojure: '(fn [text] (str text "{{planet}} => |planet|" text))'
lisp: '(lambda (text) (format nil "~a{{planet}} => |planet|~a" text text))'
pwsh: '"$($args[0]){{planet}} => |planet|$($args[0])"'
template: "{{= | | =}}<|#lambda|-|/lambda|>"
expected: "<-{{planet}} => Earth->"
- name: Section - Multiple Calls
desc: Lambdas used for sections should not be cached.
data:
lambda: !code
ruby: 'proc { |text| "__#{text}__" }'
raku: 'sub { "__" ~ $^section ~ "__" }'
perl: 'sub { "__" . $_[0] . "__" }'
js: 'function(txt) { return "__" + txt + "__" }'
php: 'return "__" . $text . "__";'
python: 'lambda text: "__%s__" % (text)'
clojure: '(fn [text] (str "__" text "__"))'
lisp: '(lambda (text) (format nil "__~a__" text))'
pwsh: '"__$($args[0])__"'
template: '{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}'
expected: '__FILE__ != __LINE__'
- name: Inverted Section
desc: Lambdas used for inverted sections should be considered truthy.
data:
static: 'static'
lambda: !code
ruby: 'proc { |text| false }'
raku: 'sub { 0 }'
perl: 'sub { 0 }'
js: 'function(txt) { return false }'
php: 'return false;'
python: 'lambda text: 0'
clojure: '(fn [text] false)'
lisp: '(lambda (text) (declare (ignore text)) nil)'
pwsh: '$false'
template: "<{{^lambda}}{{static}}{{/lambda}}>"
expected: "<>"

236
mustache_test.go Normal file
View File

@ -0,0 +1,236 @@
package handlebars
import (
"os"
"path"
"regexp"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
//
// Note, as the JS implementation, the divergences from mustache spec:
// - we don't support alternative delimeters
// - the mustache lambda spec differs
//
type mustacheTest struct {
Name string
Desc string
Data interface{}
Template string
Expected string
Partials map[string]string
}
type mustacheTestFile struct {
Overview string
Tests []mustacheTest
}
var (
rAltDelim = regexp.MustCompile(regexp.QuoteMeta("{{="))
)
var (
musTestLambdaInterMult = 0
)
func TestMustache(t *testing.T) {
t.Skip("Does not work")
skipFiles := map[string]bool{
// mustache lambdas differ from handlebars lambdas
"~lambdas.yml": true,
}
for _, fileName := range mustacheTestFiles() {
if skipFiles[fileName] {
// fmt.Printf("Skipped file: %s\n", fileName)
continue
}
launchTests(t, testsFromMustacheFile(fileName))
}
}
func testsFromMustacheFile(fileName string) []Test {
result := []Test{}
fileData, err := os.ReadFile(path.Join("mustache", "specs", fileName))
if err != nil {
panic(err)
}
var testFile mustacheTestFile
if err := yaml.Unmarshal(fileData, &testFile); err != nil {
panic(err)
}
for _, mustacheTest := range testFile.Tests {
if mustBeSkipped(mustacheTest, fileName) {
// fmt.Printf("Skipped test: %s\n", mustacheTest.Name)
continue
}
test := Test{
name: mustacheTest.Name,
input: mustacheTest.Template,
data: mustacheTest.Data,
partials: mustacheTest.Partials,
output: mustacheTest.Expected,
}
result = append(result, test)
}
return result
}
// returns true if test must be skipped
func mustBeSkipped(test mustacheTest, fileName string) bool {
// handlebars does not support alternative delimiters
return haveAltDelimiter(test) ||
// the JS implementation skips those tests
fileName == "partials.yml" && (test.Name == "Failed Lookup" || test.Name == "Standalone Indentation")
}
// returns true if test have alternative delimeter in template or in partials
func haveAltDelimiter(test mustacheTest) bool {
// check template
if rAltDelim.MatchString(test.Template) {
return true
}
// check partials
for _, partial := range test.Partials {
if rAltDelim.MatchString(partial) {
return true
}
}
return false
}
func mustacheTestFiles() []string {
var result []string
files, err := os.ReadDir(path.Join("mustache", "specs"))
if err != nil {
panic(err)
}
for _, file := range files {
fileName := file.Name()
if !file.IsDir() && strings.HasSuffix(fileName, ".yml") {
result = append(result, fileName)
}
}
return result
}
//
// Following tests come fron ~lambdas.yml
//
var mustacheLambdasTests = []Test{
{
"Interpolation",
"Hello, {{lambda}}!",
map[string]interface{}{"lambda": func() string { return "world" }},
nil, nil, nil,
"Hello, world!",
},
// // SKIP: lambda return value is not parsed
// {
// "Interpolation - Expansion",
// "Hello, {{lambda}}!",
// map[string]interface{}{"lambda": func() string { return "{{planet}}" }},
// nil, nil, nil,
// "Hello, world!",
// },
// SKIP "Interpolation - Alternate Delimiters"
{
"Interpolation - Multiple Calls",
"{{lambda}} == {{{lambda}}} == {{lambda}}",
map[string]interface{}{"lambda": func() string {
musTestLambdaInterMult++
return Str(musTestLambdaInterMult)
}},
nil, nil, nil,
"1 == 2 == 3",
},
{
"Escaping",
"<{{lambda}}{{{lambda}}}",
map[string]interface{}{"lambda": func() string { return ">" }},
nil, nil, nil,
"<&gt;>",
},
// // SKIP: "Lambdas used for sections should receive the raw section string."
// {
// "Section",
// "<{{#lambda}}{{x}}{{/lambda}}>",
// map[string]interface{}{"lambda": func(param string) string {
// if param == "{{x}}" {
// return "yes"
// }
// return "false"
// }, "x": "Error!"},
// nil, nil, nil,
// "<yes>",
// },
// // SKIP: lambda return value is not parsed
// {
// "Section - Expansion",
// "<{{#lambda}}-{{/lambda}}>",
// map[string]interface{}{"lambda": func(param string) string {
// return param + "{{planet}}" + param
// }, "planet": "Earth"},
// nil, nil, nil,
// "<-Earth->",
// },
// SKIP: "Section - Alternate Delimiters"
{
"Section - Multiple Calls",
"{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}",
map[string]interface{}{"lambda": func(options *Options) string {
return "__" + options.Fn() + "__"
}},
nil, nil, nil,
"__FILE__ != __LINE__",
},
// // SKIP: "Lambdas used for inverted sections should be considered truthy."
// {
// "Inverted Section",
// "<{{^lambda}}{{static}}{{/lambda}}>",
// map[string]interface{}{
// "lambda": func() interface{} {
// return false
// },
// "static": "static",
// },
// nil, nil, nil,
// "<>",
// },
}
func TestMustacheLambdas(t *testing.T) {
t.Parallel()
launchTests(t, mustacheLambdasTests)
}

850
parser/parser.go Normal file
View File

@ -0,0 +1,850 @@
// Package parser provides a handlebars syntax analyser. It consumes the tokens provided by the lexer to build an AST.
package parser
import (
"fmt"
"regexp"
"runtime"
"strconv"
"git.reinaldyrafli.com/aldy505/handlebars-go/ast"
"git.reinaldyrafli.com/aldy505/handlebars-go/lexer"
)
// References:
// - https://github.com/wycats/handlebars.js/blob/master/src/handlebars.yy
// - https://github.com/golang/go/blob/master/src/text/template/parse/parse.go
// parser is a syntax analyzer.
type parser struct {
// Lexer
lex *lexer.Lexer
// Root node
root ast.Node
// Tokens parsed but not consumed yet
tokens []*lexer.Token
// All tokens have been retreieved from lexer
lexOver bool
}
var (
rOpenComment = regexp.MustCompile(`^\{\{~?!-?-?`)
rCloseComment = regexp.MustCompile(`-?-?~?\}\}$`)
rOpenAmp = regexp.MustCompile(`^\{\{~?&`)
)
// new instanciates a new parser
func new(input string) *parser {
return &parser{
lex: lexer.Scan(input),
}
}
// Parse analyzes given input and returns the AST root node.
func Parse(input string) (result *ast.Program, err error) {
// recover error
defer errRecover(&err)
parser := new(input)
// parse
result = parser.parseProgram()
// check last token
token := parser.shift()
if token.Kind != lexer.TokenEOF {
// Parsing ended before EOF
errToken(token, "Syntax error")
}
// fix whitespaces
processWhitespaces(result)
// named returned values
return
}
// errRecover recovers parsing panic
func errRecover(errp *error) {
e := recover()
if e != nil {
switch err := e.(type) {
case runtime.Error:
panic(e)
case error:
*errp = err
default:
panic(e)
}
}
}
// errPanic panics
func errPanic(err error, line int) {
panic(fmt.Errorf("Parse error on line %d:\n%s", line, err))
}
// errNode panics with given node infos
func errNode(node ast.Node, msg string) {
errPanic(fmt.Errorf("%s\nNode: %s", msg, node), node.Location().Line)
}
// errNode panics with given Token infos
func errToken(tok *lexer.Token, msg string) {
errPanic(fmt.Errorf("%s\nToken: %s", msg, tok), tok.Line)
}
// errNode panics because of an unexpected Token kind
func errExpected(expect lexer.TokenKind, tok *lexer.Token) {
errPanic(fmt.Errorf("Expecting %s, got: '%s'", expect, tok), tok.Line)
}
// program : statement*
func (p *parser) parseProgram() *ast.Program {
result := ast.NewProgram(p.next().Pos, p.next().Line)
for p.isStatement() {
result.AddStatement(p.parseStatement())
}
return result
}
// statement : mustache | block | rawBlock | partial | content | COMMENT
func (p *parser) parseStatement() ast.Node {
var result ast.Node
tok := p.next()
switch tok.Kind {
case lexer.TokenOpen, lexer.TokenOpenUnescaped:
// mustache
result = p.parseMustache()
case lexer.TokenOpenBlock:
// block
result = p.parseBlock()
case lexer.TokenOpenInverse:
// block
result = p.parseInverse()
case lexer.TokenOpenRawBlock:
// rawBlock
result = p.parseRawBlock()
case lexer.TokenOpenPartial:
// partial
result = p.parsePartial()
case lexer.TokenContent:
// content
result = p.parseContent()
case lexer.TokenComment:
// COMMENT
result = p.parseComment()
}
return result
}
// isStatement returns true if next token starts a statement
func (p *parser) isStatement() bool {
if !p.have(1) {
return false
}
switch p.next().Kind {
case lexer.TokenOpen, lexer.TokenOpenUnescaped, lexer.TokenOpenBlock,
lexer.TokenOpenInverse, lexer.TokenOpenRawBlock, lexer.TokenOpenPartial,
lexer.TokenContent, lexer.TokenComment:
return true
}
return false
}
// content : CONTENT
func (p *parser) parseContent() *ast.ContentStatement {
// CONTENT
tok := p.shift()
if tok.Kind != lexer.TokenContent {
// @todo This check can be removed if content is optional in a raw block
errExpected(lexer.TokenContent, tok)
}
return ast.NewContentStatement(tok.Pos, tok.Line, tok.Val)
}
// COMMENT
func (p *parser) parseComment() *ast.CommentStatement {
// COMMENT
tok := p.shift()
value := rOpenComment.ReplaceAllString(tok.Val, "")
value = rCloseComment.ReplaceAllString(value, "")
result := ast.NewCommentStatement(tok.Pos, tok.Line, value)
result.Strip = ast.NewStripForStr(tok.Val)
return result
}
// param* hash?
func (p *parser) parseExpressionParamsHash() ([]ast.Node, *ast.Hash) {
var params []ast.Node
var hash *ast.Hash
// params*
if p.isParam() {
params = p.parseParams()
}
// hash?
if p.isHashSegment() {
hash = p.parseHash()
}
return params, hash
}
// helperName param* hash?
func (p *parser) parseExpression(tok *lexer.Token) *ast.Expression {
result := ast.NewExpression(tok.Pos, tok.Line)
// helperName
result.Path = p.parseHelperName()
// param* hash?
result.Params, result.Hash = p.parseExpressionParamsHash()
return result
}
// rawBlock : openRawBlock content endRawBlock
// openRawBlock : OPEN_RAW_BLOCK helperName param* hash? CLOSE_RAW_BLOCK
// endRawBlock : OPEN_END_RAW_BLOCK helperName CLOSE_RAW_BLOCK
func (p *parser) parseRawBlock() *ast.BlockStatement {
// OPEN_RAW_BLOCK
tok := p.shift()
result := ast.NewBlockStatement(tok.Pos, tok.Line)
// helperName param* hash?
result.Expression = p.parseExpression(tok)
openName := result.Expression.Canonical()
// CLOSE_RAW_BLOCK
tok = p.shift()
if tok.Kind != lexer.TokenCloseRawBlock {
errExpected(lexer.TokenCloseRawBlock, tok)
}
// content
// @todo Is content mandatory in a raw block ?
content := p.parseContent()
program := ast.NewProgram(tok.Pos, tok.Line)
program.AddStatement(content)
result.Program = program
// OPEN_END_RAW_BLOCK
tok = p.shift()
if tok.Kind != lexer.TokenOpenEndRawBlock {
// should never happen as it is caught by lexer
errExpected(lexer.TokenOpenEndRawBlock, tok)
}
// helperName
endID := p.parseHelperName()
closeName, ok := ast.HelperNameStr(endID)
if !ok {
errNode(endID, "Erroneous closing expression")
}
if openName != closeName {
errNode(endID, fmt.Sprintf("%s doesn't match %s", openName, closeName))
}
// CLOSE_RAW_BLOCK
tok = p.shift()
if tok.Kind != lexer.TokenCloseRawBlock {
errExpected(lexer.TokenCloseRawBlock, tok)
}
return result
}
// block : openBlock program inverseChain? closeBlock
func (p *parser) parseBlock() *ast.BlockStatement {
// openBlock
result, blockParams := p.parseOpenBlock()
// program
program := p.parseProgram()
program.BlockParams = blockParams
result.Program = program
// inverseChain?
if p.isInverseChain() {
result.Inverse = p.parseInverseChain()
}
// closeBlock
p.parseCloseBlock(result)
setBlockInverseStrip(result)
return result
}
// setBlockInverseStrip is called when parsing `block` (openBlock | openInverse) and `inverseChain`
//
// TODO: This was totally cargo culted ! CHECK THAT !
//
// cf. prepareBlock() in:
//
// https://github.com/wycats/handlebars.js/blob/master/lib/handlebars/compiler/helper.js
func setBlockInverseStrip(block *ast.BlockStatement) {
if block.Inverse == nil {
return
}
if block.Inverse.Chained {
b, _ := block.Inverse.Body[0].(*ast.BlockStatement)
b.CloseStrip = block.CloseStrip
}
block.InverseStrip = block.Inverse.Strip
}
// block : openInverse program inverseAndProgram? closeBlock
func (p *parser) parseInverse() *ast.BlockStatement {
// openInverse
result, blockParams := p.parseOpenBlock()
// program
program := p.parseProgram()
program.BlockParams = blockParams
result.Inverse = program
// inverseAndProgram?
if p.isInverse() {
result.Program = p.parseInverseAndProgram()
}
// closeBlock
p.parseCloseBlock(result)
setBlockInverseStrip(result)
return result
}
// helperName param* hash? blockParams?
func (p *parser) parseOpenBlockExpression(tok *lexer.Token) (*ast.BlockStatement, []string) {
var blockParams []string
result := ast.NewBlockStatement(tok.Pos, tok.Line)
// helperName param* hash?
result.Expression = p.parseExpression(tok)
// blockParams?
if p.isBlockParams() {
blockParams = p.parseBlockParams()
}
// named returned values
return result, blockParams
}
// inverseChain : openInverseChain program inverseChain?
//
// | inverseAndProgram
func (p *parser) parseInverseChain() *ast.Program {
if p.isInverse() {
// inverseAndProgram
return p.parseInverseAndProgram()
}
result := ast.NewProgram(p.next().Pos, p.next().Line)
// openInverseChain
block, blockParams := p.parseOpenBlock()
// program
program := p.parseProgram()
program.BlockParams = blockParams
block.Program = program
// inverseChain?
if p.isInverseChain() {
block.Inverse = p.parseInverseChain()
}
setBlockInverseStrip(block)
result.Chained = true
result.AddStatement(block)
return result
}
// Returns true if current token starts an inverse chain
func (p *parser) isInverseChain() bool {
return p.isOpenInverseChain() || p.isInverse()
}
// inverseAndProgram : INVERSE program
func (p *parser) parseInverseAndProgram() *ast.Program {
// INVERSE
tok := p.shift()
// program
result := p.parseProgram()
result.Strip = ast.NewStripForStr(tok.Val)
return result
}
// openBlock : OPEN_BLOCK helperName param* hash? blockParams? CLOSE
// openInverse : OPEN_INVERSE helperName param* hash? blockParams? CLOSE
// openInverseChain: OPEN_INVERSE_CHAIN helperName param* hash? blockParams? CLOSE
func (p *parser) parseOpenBlock() (*ast.BlockStatement, []string) {
// OPEN_BLOCK | OPEN_INVERSE | OPEN_INVERSE_CHAIN
tok := p.shift()
// helperName param* hash? blockParams?
result, blockParams := p.parseOpenBlockExpression(tok)
// CLOSE
tokClose := p.shift()
if tokClose.Kind != lexer.TokenClose {
errExpected(lexer.TokenClose, tokClose)
}
result.OpenStrip = ast.NewStrip(tok.Val, tokClose.Val)
// named returned values
return result, blockParams
}
// closeBlock : OPEN_ENDBLOCK helperName CLOSE
func (p *parser) parseCloseBlock(block *ast.BlockStatement) {
// OPEN_ENDBLOCK
tok := p.shift()
if tok.Kind != lexer.TokenOpenEndBlock {
errExpected(lexer.TokenOpenEndBlock, tok)
}
// helperName
endID := p.parseHelperName()
closeName, ok := ast.HelperNameStr(endID)
if !ok {
errNode(endID, "Erroneous closing expression")
}
openName := block.Expression.Canonical()
if openName != closeName {
errNode(endID, fmt.Sprintf("%s doesn't match %s", openName, closeName))
}
// CLOSE
tokClose := p.shift()
if tokClose.Kind != lexer.TokenClose {
errExpected(lexer.TokenClose, tokClose)
}
block.CloseStrip = ast.NewStrip(tok.Val, tokClose.Val)
}
// mustache : OPEN helperName param* hash? CLOSE
//
// | OPEN_UNESCAPED helperName param* hash? CLOSE_UNESCAPED
func (p *parser) parseMustache() *ast.MustacheStatement {
// OPEN | OPEN_UNESCAPED
tok := p.shift()
closeToken := lexer.TokenClose
if tok.Kind == lexer.TokenOpenUnescaped {
closeToken = lexer.TokenCloseUnescaped
}
unescaped := false
if (tok.Kind == lexer.TokenOpenUnescaped) || (rOpenAmp.MatchString(tok.Val)) {
unescaped = true
}
result := ast.NewMustacheStatement(tok.Pos, tok.Line, unescaped)
// helperName param* hash?
result.Expression = p.parseExpression(tok)
// CLOSE | CLOSE_UNESCAPED
tokClose := p.shift()
if tokClose.Kind != closeToken {
errExpected(closeToken, tokClose)
}
result.Strip = ast.NewStrip(tok.Val, tokClose.Val)
return result
}
// partial : OPEN_PARTIAL partialName param* hash? CLOSE
func (p *parser) parsePartial() *ast.PartialStatement {
// OPEN_PARTIAL
tok := p.shift()
result := ast.NewPartialStatement(tok.Pos, tok.Line)
// partialName
result.Name = p.parsePartialName()
// param* hash?
result.Params, result.Hash = p.parseExpressionParamsHash()
// CLOSE
tokClose := p.shift()
if tokClose.Kind != lexer.TokenClose {
errExpected(lexer.TokenClose, tokClose)
}
result.Strip = ast.NewStrip(tok.Val, tokClose.Val)
return result
}
// helperName | sexpr
func (p *parser) parseHelperNameOrSexpr() ast.Node {
if p.isSexpr() {
// sexpr
return p.parseSexpr()
}
// helperName
return p.parseHelperName()
}
// param : helperName | sexpr
func (p *parser) parseParam() ast.Node {
return p.parseHelperNameOrSexpr()
}
// Returns true if next tokens represent a `param`
func (p *parser) isParam() bool {
return (p.isSexpr() || p.isHelperName()) && !p.isHashSegment()
}
// param*
func (p *parser) parseParams() []ast.Node {
var result []ast.Node
for p.isParam() {
result = append(result, p.parseParam())
}
return result
}
// sexpr : OPEN_SEXPR helperName param* hash? CLOSE_SEXPR
func (p *parser) parseSexpr() *ast.SubExpression {
// OPEN_SEXPR
tok := p.shift()
result := ast.NewSubExpression(tok.Pos, tok.Line)
// helperName param* hash?
result.Expression = p.parseExpression(tok)
// CLOSE_SEXPR
tok = p.shift()
if tok.Kind != lexer.TokenCloseSexpr {
errExpected(lexer.TokenCloseSexpr, tok)
}
return result
}
// hash : hashSegment+
func (p *parser) parseHash() *ast.Hash {
var pairs []*ast.HashPair
for p.isHashSegment() {
pairs = append(pairs, p.parseHashSegment())
}
firstLoc := pairs[0].Location()
result := ast.NewHash(firstLoc.Pos, firstLoc.Line)
result.Pairs = pairs
return result
}
// returns true if next tokens represents a `hashSegment`
func (p *parser) isHashSegment() bool {
return p.have(2) && (p.next().Kind == lexer.TokenID) && (p.nextAt(1).Kind == lexer.TokenEquals)
}
// hashSegment : ID EQUALS param
func (p *parser) parseHashSegment() *ast.HashPair {
// ID
tok := p.shift()
// EQUALS
p.shift()
// param
param := p.parseParam()
result := ast.NewHashPair(tok.Pos, tok.Line)
result.Key = tok.Val
result.Val = param
return result
}
// blockParams : OPEN_BLOCK_PARAMS ID+ CLOSE_BLOCK_PARAMS
func (p *parser) parseBlockParams() []string {
var result []string
// OPEN_BLOCK_PARAMS
tok := p.shift()
// ID+
for p.isID() {
result = append(result, p.shift().Val)
}
if len(result) == 0 {
errExpected(lexer.TokenID, p.next())
}
// CLOSE_BLOCK_PARAMS
tok = p.shift()
if tok.Kind != lexer.TokenCloseBlockParams {
errExpected(lexer.TokenCloseBlockParams, tok)
}
return result
}
// helperName : path | dataName | STRING | NUMBER | BOOLEAN | UNDEFINED | NULL
func (p *parser) parseHelperName() ast.Node {
var result ast.Node
tok := p.next()
switch tok.Kind {
case lexer.TokenBoolean:
// BOOLEAN
p.shift()
result = ast.NewBooleanLiteral(tok.Pos, tok.Line, (tok.Val == "true"), tok.Val)
case lexer.TokenNumber:
// NUMBER
p.shift()
val, isInt := parseNumber(tok)
result = ast.NewNumberLiteral(tok.Pos, tok.Line, val, isInt, tok.Val)
case lexer.TokenString:
// STRING
p.shift()
result = ast.NewStringLiteral(tok.Pos, tok.Line, tok.Val)
case lexer.TokenData:
// dataName
result = p.parseDataName()
default:
// path
result = p.parsePath(false)
}
return result
}
// parseNumber parses a number
func parseNumber(tok *lexer.Token) (result float64, isInt bool) {
var valInt int
var err error
valInt, err = strconv.Atoi(tok.Val)
if err == nil {
isInt = true
result = float64(valInt)
} else {
isInt = false
result, err = strconv.ParseFloat(tok.Val, 64)
if err != nil {
errToken(tok, fmt.Sprintf("Failed to parse number: %s", tok.Val))
}
}
// named returned values
return
}
// Returns true if next tokens represent a `helperName`
func (p *parser) isHelperName() bool {
switch p.next().Kind {
case lexer.TokenBoolean, lexer.TokenNumber, lexer.TokenString, lexer.TokenData, lexer.TokenID:
return true
}
return false
}
// partialName : helperName | sexpr
func (p *parser) parsePartialName() ast.Node {
return p.parseHelperNameOrSexpr()
}
// dataName : DATA pathSegments
func (p *parser) parseDataName() *ast.PathExpression {
// DATA
p.shift()
// pathSegments
return p.parsePath(true)
}
// path : pathSegments
// pathSegments : pathSegments SEP ID
//
// | ID
func (p *parser) parsePath(data bool) *ast.PathExpression {
var tok *lexer.Token
// ID
tok = p.shift()
if tok.Kind != lexer.TokenID {
errExpected(lexer.TokenID, tok)
}
result := ast.NewPathExpression(tok.Pos, tok.Line, data)
result.Part(tok.Val)
for p.isPathSep() {
// SEP
tok = p.shift()
result.Sep(tok.Val)
// ID
tok = p.shift()
if tok.Kind != lexer.TokenID {
errExpected(lexer.TokenID, tok)
}
result.Part(tok.Val)
if len(result.Parts) > 0 {
switch tok.Val {
case "..", ".", "this":
errToken(tok, "Invalid path: "+result.Original)
}
}
}
return result
}
// Ensures there is token to parse at given index
func (p *parser) ensure(index int) {
if p.lexOver {
// nothing more to grab
return
}
nb := index + 1
for len(p.tokens) < nb {
// fetch next token
tok := p.lex.NextToken()
// queue it
p.tokens = append(p.tokens, &tok)
if (tok.Kind == lexer.TokenEOF) || (tok.Kind == lexer.TokenError) {
p.lexOver = true
break
}
}
}
// have returns true is there are a list given number of tokens to consume left
func (p *parser) have(nb int) bool {
p.ensure(nb - 1)
return len(p.tokens) >= nb
}
// nextAt returns next token at given index, without consuming it
func (p *parser) nextAt(index int) *lexer.Token {
p.ensure(index)
return p.tokens[index]
}
// next returns next token without consuming it
func (p *parser) next() *lexer.Token {
return p.nextAt(0)
}
// shift returns next token and remove it from the tokens buffer
//
// Panics if next token is `TokenError`
func (p *parser) shift() *lexer.Token {
var result *lexer.Token
p.ensure(0)
result, p.tokens = p.tokens[0], p.tokens[1:]
// check error token
if result.Kind == lexer.TokenError {
errToken(result, "Lexer error")
}
return result
}
// isToken returns true if next token is of given type
func (p *parser) isToken(kind lexer.TokenKind) bool {
return p.have(1) && p.next().Kind == kind
}
// isSexpr returns true if next token starts a sexpr
func (p *parser) isSexpr() bool {
return p.isToken(lexer.TokenOpenSexpr)
}
// isPathSep returns true if next token is a path separator
func (p *parser) isPathSep() bool {
return p.isToken(lexer.TokenSep)
}
// isID returns true if next token is an ID
func (p *parser) isID() bool {
return p.isToken(lexer.TokenID)
}
// isBlockParams returns true if next token starts a block params
func (p *parser) isBlockParams() bool {
return p.isToken(lexer.TokenOpenBlockParams)
}
// isInverse returns true if next token starts an INVERSE sequence
func (p *parser) isInverse() bool {
return p.isToken(lexer.TokenInverse)
}
// isOpenInverseChain returns true if next token is OPEN_INVERSE_CHAIN
func (p *parser) isOpenInverseChain() bool {
return p.isToken(lexer.TokenOpenInverseChain)
}

200
parser/parser_test.go Normal file
View File

@ -0,0 +1,200 @@
package parser
import (
"fmt"
"regexp"
"testing"
"git.reinaldyrafli.com/aldy505/handlebars-go/ast"
"git.reinaldyrafli.com/aldy505/handlebars-go/lexer"
)
type parserTest struct {
name string
input string
output string
}
var parserTests = []parserTest{
//
// Next tests come from:
// https://github.com/wycats/handlebars.js/blob/master/spec/parser.js
//
{"parses simple mustaches (1)", `{{123}}`, "{{ NUMBER{123} [] }}\n"},
{"parses simple mustaches (2)", `{{"foo"}}`, "{{ \"foo\" [] }}\n"},
{"parses simple mustaches (3)", `{{false}}`, "{{ BOOLEAN{false} [] }}\n"},
{"parses simple mustaches (4)", `{{true}}`, "{{ BOOLEAN{true} [] }}\n"},
{"parses simple mustaches (5)", `{{foo}}`, "{{ PATH:foo [] }}\n"},
{"parses simple mustaches (6)", `{{foo?}}`, "{{ PATH:foo? [] }}\n"},
{"parses simple mustaches (7)", `{{foo_}}`, "{{ PATH:foo_ [] }}\n"},
{"parses simple mustaches (8)", `{{foo-}}`, "{{ PATH:foo- [] }}\n"},
{"parses simple mustaches (9)", `{{foo:}}`, "{{ PATH:foo: [] }}\n"},
{"parses simple mustaches with data", `{{@foo}}`, "{{ @PATH:foo [] }}\n"},
{"parses simple mustaches with data paths", `{{@../foo}}`, "{{ @PATH:foo [] }}\n"},
{"parses mustaches with paths", `{{foo/bar}}`, "{{ PATH:foo/bar [] }}\n"},
{"parses mustaches with this/foo", `{{this/foo}}`, "{{ PATH:foo [] }}\n"},
{"parses mustaches with - in a path", `{{foo-bar}}`, "{{ PATH:foo-bar [] }}\n"},
{"parses mustaches with parameters", `{{foo bar}}`, "{{ PATH:foo [PATH:bar] }}\n"},
{"parses mustaches with string parameters", `{{foo bar "baz" }}`, "{{ PATH:foo [PATH:bar, \"baz\"] }}\n"},
{"parses mustaches with NUMBER parameters", `{{foo 1}}`, "{{ PATH:foo [NUMBER{1}] }}\n"},
{"parses mustaches with BOOLEAN parameters (1)", `{{foo true}}`, "{{ PATH:foo [BOOLEAN{true}] }}\n"},
{"parses mustaches with BOOLEAN parameters (2)", `{{foo false}}`, "{{ PATH:foo [BOOLEAN{false}] }}\n"},
{"parses mustaches with DATA parameters", `{{foo @bar}}`, "{{ PATH:foo [@PATH:bar] }}\n"},
{"parses mustaches with hash arguments (01)", `{{foo bar=baz}}`, "{{ PATH:foo [] HASH{bar=PATH:baz} }}\n"},
{"parses mustaches with hash arguments (02)", `{{foo bar=1}}`, "{{ PATH:foo [] HASH{bar=NUMBER{1}} }}\n"},
{"parses mustaches with hash arguments (03)", `{{foo bar=true}}`, "{{ PATH:foo [] HASH{bar=BOOLEAN{true}} }}\n"},
{"parses mustaches with hash arguments (04)", `{{foo bar=false}}`, "{{ PATH:foo [] HASH{bar=BOOLEAN{false}} }}\n"},
{"parses mustaches with hash arguments (05)", `{{foo bar=@baz}}`, "{{ PATH:foo [] HASH{bar=@PATH:baz} }}\n"},
{"parses mustaches with hash arguments (06)", `{{foo bar=baz bat=bam}}`, "{{ PATH:foo [] HASH{bar=PATH:baz, bat=PATH:bam} }}\n"},
{"parses mustaches with hash arguments (07)", `{{foo bar=baz bat="bam"}}`, "{{ PATH:foo [] HASH{bar=PATH:baz, bat=\"bam\"} }}\n"},
{"parses mustaches with hash arguments (08)", `{{foo bat='bam'}}`, "{{ PATH:foo [] HASH{bat=\"bam\"} }}\n"},
{"parses mustaches with hash arguments (09)", `{{foo omg bar=baz bat="bam"}}`, "{{ PATH:foo [PATH:omg] HASH{bar=PATH:baz, bat=\"bam\"} }}\n"},
{"parses mustaches with hash arguments (10)", `{{foo omg bar=baz bat="bam" baz=1}}`, "{{ PATH:foo [PATH:omg] HASH{bar=PATH:baz, bat=\"bam\", baz=NUMBER{1}} }}\n"},
{"parses mustaches with hash arguments (11)", `{{foo omg bar=baz bat="bam" baz=true}}`, "{{ PATH:foo [PATH:omg] HASH{bar=PATH:baz, bat=\"bam\", baz=BOOLEAN{true}} }}\n"},
{"parses mustaches with hash arguments (12)", `{{foo omg bar=baz bat="bam" baz=false}}`, "{{ PATH:foo [PATH:omg] HASH{bar=PATH:baz, bat=\"bam\", baz=BOOLEAN{false}} }}\n"},
{"parses contents followed by a mustache", `foo bar {{baz}}`, "CONTENT[ 'foo bar ' ]\n{{ PATH:baz [] }}\n"},
{"parses a partial (1)", `{{> foo }}`, "{{> PARTIAL:foo }}\n"},
{"parses a partial (2)", `{{> "foo" }}`, "{{> PARTIAL:foo }}\n"},
{"parses a partial (3)", `{{> 1 }}`, "{{> PARTIAL:1 }}\n"},
{"parses a partial with context", `{{> foo bar}}`, "{{> PARTIAL:foo PATH:bar }}\n"},
{"parses a partial with hash", `{{> foo bar=bat}}`, "{{> PARTIAL:foo HASH{bar=PATH:bat} }}\n"},
{"parses a partial with context and hash", `{{> foo bar bat=baz}}`, "{{> PARTIAL:foo PATH:bar HASH{bat=PATH:baz} }}\n"},
{"parses a partial with a complex name", `{{> shared/partial?.bar}}`, "{{> PARTIAL:shared/partial?.bar }}\n"},
{"parses a comment", `{{! this is a comment }}`, "{{! ' this is a comment ' }}\n"},
{"parses a multi-line comment", "{{!\nthis is a multi-line comment\n}}", "{{! '\nthis is a multi-line comment\n' }}\n"},
{"parses an inverse section", `{{#foo}} bar {{^}} baz {{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n CONTENT[ ' bar ' ]\n {{^}}\n CONTENT[ ' baz ' ]\n"},
{"parses an inverse (else-style) section", `{{#foo}} bar {{else}} baz {{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n CONTENT[ ' bar ' ]\n {{^}}\n CONTENT[ ' baz ' ]\n"},
{"parses multiple inverse sections", `{{#foo}} bar {{else if bar}}{{else}} baz {{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n CONTENT[ ' bar ' ]\n {{^}}\n BLOCK:\n PATH:if [PATH:bar]\n PROGRAM:\n {{^}}\n CONTENT[ ' baz ' ]\n"},
{"parses empty blocks", `{{#foo}}{{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n"},
{"parses empty blocks with empty inverse section", `{{#foo}}{{^}}{{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n {{^}}\n"},
{"parses empty blocks with empty inverse (else-style) section", `{{#foo}}{{else}}{{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n {{^}}\n"},
{"parses non-empty blocks with empty inverse section", `{{#foo}} bar {{^}}{{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n CONTENT[ ' bar ' ]\n {{^}}\n"},
{"parses non-empty blocks with empty inverse (else-style) section", `{{#foo}} bar {{else}}{{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n CONTENT[ ' bar ' ]\n {{^}}\n"},
{"parses empty blocks with non-empty inverse section", `{{#foo}}{{^}} bar {{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n {{^}}\n CONTENT[ ' bar ' ]\n"},
{"parses empty blocks with non-empty inverse (else-style) section", `{{#foo}}{{else}} bar {{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n {{^}}\n CONTENT[ ' bar ' ]\n"},
{"parses a standalone inverse section", `{{^foo}}bar{{/foo}}`, "BLOCK:\n PATH:foo []\n {{^}}\n CONTENT[ 'bar' ]\n"},
{"parses block with block params", `{{#foo as |bar baz|}}content{{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n BLOCK PARAMS: [ bar baz ]\n CONTENT[ 'content' ]\n"},
{"parses inverse block with block params", `{{^foo as |bar baz|}}content{{/foo}}`, "BLOCK:\n PATH:foo []\n {{^}}\n BLOCK PARAMS: [ bar baz ]\n CONTENT[ 'content' ]\n"},
{"parses chained inverse block with block params", `{{#foo}}{{else foo as |bar baz|}}content{{/foo}}`, "BLOCK:\n PATH:foo []\n PROGRAM:\n {{^}}\n BLOCK:\n PATH:foo []\n PROGRAM:\n BLOCK PARAMS: [ bar baz ]\n CONTENT[ 'content' ]\n"},
}
func TestParser(t *testing.T) {
t.Parallel()
for _, test := range parserTests {
output := ""
node, err := Parse(test.input)
if err == nil {
output = ast.Print(node)
}
if (err != nil) || (test.output != output) {
t.Errorf("Test '%s' failed\ninput:\n\t'%s'\nexpected\n\t%q\ngot\n\t%q\nerror:\n\t%s", test.name, test.input, test.output, output, err)
}
}
}
var parserErrorTests = []parserTest{
{"lexer error", `{{! unclosed comment`, "Lexer error"},
{"syntax error", `foo{{^}}`, "Syntax error"},
{"open raw block must be closed", `{{{{raw foo}} bar {{{{/raw}}}}`, "Expecting CloseRawBlock"},
{"end raw block must be closed", `{{{{raw foo}}}} bar {{{{/raw}}`, "Expecting CloseRawBlock"},
{"raw block names must match (1)", `{{{{1}}}}{{foo}}{{{{/raw}}}}`, "1 doesn't match raw"},
{"raw block names must match (2)", `{{{{raw}}}}{{foo}}{{{{/1}}}}`, "raw doesn't match 1"},
{"raw block names must match (3)", `{{{{goodbyes}}}}test{{{{/hellos}}}}`, "goodbyes doesn't match hellos"},
{"open block must be closed", `{{#foo bar}}}{{/foo}}`, "Expecting Close"},
{"end block must be closed", `{{#foo bar}}{{/foo}}}`, "Expecting Close"},
{"an open block must have a end block", `{{#foo}}test`, "Expecting OpenEndBlock"},
{"block names must match (1)", `{{#1 bar}}{{/foo}}`, "1 doesn't match foo"},
{"block names must match (2)", `{{#foo bar}}{{/1}}`, "foo doesn't match 1"},
{"block names must match (3)", `{{#foo}}test{{/bar}}`, "foo doesn't match bar"},
{"an mustache must terminate with a close mustache", `{{foo}}}`, "Expecting Close"},
{"an unescaped mustache must terminate with a close unescaped mustache", `{{{foo}}`, "Expecting CloseUnescaped"},
{"an partial must terminate with a close mustache", `{{> foo}}}`, "Expecting Close"},
{"a subexpression must terminate with a close subexpression", `{{foo (false}}`, "Expecting CloseSexpr"},
{"raises on missing hash value (1)", `{{foo bar=}}`, "Parse error on line 1"},
{"raises on missing hash value (2)", `{{foo bar=baz bim=}}`, "Parse error on line 1"},
{"block param must have at least one param", `{{#foo as ||}}content{{/foo}}`, "Expecting ID"},
{"open block params must be closed", `{{#foo as |}}content{{/foo}}`, "Expecting ID"},
{"a path must start with an ID", `{{#/}}content{{/foo}}`, "Expecting ID"},
{"a path must end with an ID", `{{foo/bar/}}`, "Expecting ID"},
//
// Next tests come from:
// https://github.com/wycats/handlebars.js/blob/master/spec/parser.js
//
{"throws on old inverse section", `{{else foo}}bar{{/foo}}`, ""},
{"raises if there's a parser error (1)", `foo{{^}}bar`, "Parse error on line 1"},
{"raises if there's a parser error (2)", `{{foo}`, "Parse error on line 1"},
{"raises if there's a parser error (3)", `{{foo &}}`, "Parse error on line 1"},
{"raises if there's a parser error (4)", `{{#goodbyes}}{{/hellos}}`, "Parse error on line 1"},
{"raises if there's a parser error (5)", `{{#goodbyes}}{{/hellos}}`, "goodbyes doesn't match hellos"},
{"should handle invalid paths (1)", `{{foo/../bar}}`, `Invalid path: foo/..`},
{"should handle invalid paths (2)", `{{foo/./bar}}`, `Invalid path: foo/.`},
{"should handle invalid paths (3)", `{{foo/this/bar}}`, `Invalid path: foo/this`},
{"knows how to report the correct line number in errors (1)", "hello\nmy\n{{foo}", "Parse error on line 3"},
{"knows how to report the correct line number in errors (2)", "hello\n\nmy\n\n{{foo}", "Parse error on line 5"},
{"knows how to report the correct line number in errors when the first character is a newline", "\n\nhello\n\nmy\n\n{{foo}", "Parse error on line 7"},
}
func TestParserErrors(t *testing.T) {
t.Parallel()
for _, test := range parserErrorTests {
node, err := Parse(test.input)
if err == nil {
output := ast.Print(node)
tokens := lexer.Collect(test.input)
t.Errorf("Test '%s' failed - Error expected\ninput:\n\t'%s'\ngot\n\t%q\ntokens:\n\t%q", test.name, test.input, output, tokens)
} else if test.output != "" {
matched, errMatch := regexp.MatchString(regexp.QuoteMeta(test.output), fmt.Sprint(err))
if errMatch != nil {
panic("Failed to match regexp")
}
if !matched {
t.Errorf("Test '%s' failed - Incorrect error returned\ninput:\n\t'%s'\nexpected\n\t%q\ngot\n\t%q", test.name, test.input, test.output, err)
}
}
}
}
// package example
func Example() {
source := "You know {{nothing}} John Snow"
// parse template
program, err := Parse(source)
if err != nil {
panic(err)
}
// print AST
output := ast.Print(program)
fmt.Print(output)
// CONTENT[ 'You know ' ]
// {{ PATH:nothing [] }}
// CONTENT[ ' John Snow' ]
}

361
parser/whitespace.go Normal file
View File

@ -0,0 +1,361 @@
package parser
import (
"regexp"
"git.reinaldyrafli.com/aldy505/handlebars-go/ast"
)
// whitespaceVisitor walks through the AST to perform whitespace control
//
// The logic was shamelessly borrowed from:
//
// https://github.com/wycats/handlebars.js/blob/master/lib/handlebars/compiler/whitespace-control.js
type whitespaceVisitor struct {
isRootSeen bool
}
var (
rTrimLeft = regexp.MustCompile(`^[ \t]*\r?\n?`)
rTrimLeftMultiple = regexp.MustCompile(`^\s+`)
rTrimRight = regexp.MustCompile(`[ \t]+$`)
rTrimRightMultiple = regexp.MustCompile(`\s+$`)
rPrevWhitespace = regexp.MustCompile(`\r?\n\s*?$`)
rPrevWhitespaceStart = regexp.MustCompile(`(^|\r?\n)\s*?$`)
rNextWhitespace = regexp.MustCompile(`^\s*?\r?\n`)
rNextWhitespaceEnd = regexp.MustCompile(`^\s*?(\r?\n|$)`)
rPartialIndent = regexp.MustCompile(`([ \t]+$)`)
)
// newWhitespaceVisitor instanciates a new whitespaceVisitor
func newWhitespaceVisitor() *whitespaceVisitor {
return &whitespaceVisitor{}
}
// processWhitespaces performs whitespace control on given AST
//
// WARNING: It must be called only once on AST.
func processWhitespaces(node ast.Node) {
node.Accept(newWhitespaceVisitor())
}
func omitRightFirst(body []ast.Node, multiple bool) {
omitRight(body, -1, multiple)
}
func omitRight(body []ast.Node, i int, multiple bool) {
if i+1 >= len(body) {
return
}
current := body[i+1]
node, ok := current.(*ast.ContentStatement)
if !ok {
return
}
if !multiple && node.RightStripped {
return
}
original := node.Value
r := rTrimLeft
if multiple {
r = rTrimLeftMultiple
}
node.Value = r.ReplaceAllString(node.Value, "")
node.RightStripped = (original != node.Value)
}
func omitLeftLast(body []ast.Node, multiple bool) {
omitLeft(body, len(body), multiple)
}
func omitLeft(body []ast.Node, i int, multiple bool) bool {
if i-1 < 0 {
return false
}
current := body[i-1]
node, ok := current.(*ast.ContentStatement)
if !ok {
return false
}
if !multiple && node.LeftStripped {
return false
}
original := node.Value
r := rTrimRight
if multiple {
r = rTrimRightMultiple
}
node.Value = r.ReplaceAllString(node.Value, "")
node.LeftStripped = (original != node.Value)
return node.LeftStripped
}
func isPrevWhitespace(body []ast.Node) bool {
return isPrevWhitespaceProgram(body, len(body), false)
}
func isPrevWhitespaceProgram(body []ast.Node, i int, isRoot bool) bool {
if i < 1 {
return isRoot
}
prev := body[i-1]
if node, ok := prev.(*ast.ContentStatement); ok {
if (node.Value == "") && node.RightStripped {
// already stripped, so it may be an empty string not catched by regexp
return true
}
r := rPrevWhitespaceStart
if (i > 1) || !isRoot {
r = rPrevWhitespace
}
return r.MatchString(node.Value)
}
return false
}
func isNextWhitespace(body []ast.Node) bool {
return isNextWhitespaceProgram(body, -1, false)
}
func isNextWhitespaceProgram(body []ast.Node, i int, isRoot bool) bool {
if i+1 >= len(body) {
return isRoot
}
next := body[i+1]
if node, ok := next.(*ast.ContentStatement); ok {
if (node.Value == "") && node.LeftStripped {
// already stripped, so it may be an empty string not catched by regexp
return true
}
r := rNextWhitespaceEnd
if (i+2 > len(body)) || !isRoot {
r = rNextWhitespace
}
return r.MatchString(node.Value)
}
return false
}
//
// Visitor interface
//
func (v *whitespaceVisitor) VisitProgram(program *ast.Program) interface{} {
isRoot := !v.isRootSeen
v.isRootSeen = true
body := program.Body
for i, current := range body {
strip, _ := current.Accept(v).(*ast.Strip)
if strip == nil {
continue
}
_isPrevWhitespace := isPrevWhitespaceProgram(body, i, isRoot)
_isNextWhitespace := isNextWhitespaceProgram(body, i, isRoot)
openStandalone := strip.OpenStandalone && _isPrevWhitespace
closeStandalone := strip.CloseStandalone && _isNextWhitespace
inlineStandalone := strip.InlineStandalone && _isPrevWhitespace && _isNextWhitespace
if strip.Close {
omitRight(body, i, true)
}
if strip.Open && (i > 0) {
omitLeft(body, i, true)
}
if inlineStandalone {
omitRight(body, i, false)
if omitLeft(body, i, false) {
// If we are on a standalone node, save the indent info for partials
if partial, ok := current.(*ast.PartialStatement); ok {
// Pull out the whitespace from the final line
if i > 0 {
if prevContent, ok := body[i-1].(*ast.ContentStatement); ok {
partial.Indent = rPartialIndent.FindString(prevContent.Original)
}
}
}
}
}
if b, ok := current.(*ast.BlockStatement); ok {
if openStandalone {
prog := b.Program
if prog == nil {
prog = b.Inverse
}
omitRightFirst(prog.Body, false)
// Strip out the previous content node if it's whitespace only
omitLeft(body, i, false)
}
if closeStandalone {
prog := b.Inverse
if prog == nil {
prog = b.Program
}
// Always strip the next node
omitRight(body, i, false)
omitLeftLast(prog.Body, false)
}
}
}
return nil
}
func (v *whitespaceVisitor) VisitBlock(block *ast.BlockStatement) interface{} {
if block.Program != nil {
block.Program.Accept(v)
}
if block.Inverse != nil {
block.Inverse.Accept(v)
}
program := block.Program
inverse := block.Inverse
if program == nil {
program = inverse
inverse = nil
}
firstInverse := inverse
lastInverse := inverse
if (inverse != nil) && inverse.Chained {
b, _ := inverse.Body[0].(*ast.BlockStatement)
firstInverse = b.Program
for lastInverse.Chained {
b, _ := lastInverse.Body[len(lastInverse.Body)-1].(*ast.BlockStatement)
lastInverse = b.Program
}
}
closeProg := firstInverse
if closeProg == nil {
closeProg = program
}
strip := &ast.Strip{
Open: (block.OpenStrip != nil) && block.OpenStrip.Open,
Close: (block.CloseStrip != nil) && block.CloseStrip.Close,
OpenStandalone: isNextWhitespace(program.Body),
CloseStandalone: isPrevWhitespace(closeProg.Body),
}
if (block.OpenStrip != nil) && block.OpenStrip.Close {
omitRightFirst(program.Body, true)
}
if inverse != nil {
if block.InverseStrip != nil {
inverseStrip := block.InverseStrip
if inverseStrip.Open {
omitLeftLast(program.Body, true)
}
if inverseStrip.Close {
omitRightFirst(firstInverse.Body, true)
}
}
if (block.CloseStrip != nil) && block.CloseStrip.Open {
omitLeftLast(lastInverse.Body, true)
}
// Find standalone else statements
if isPrevWhitespace(program.Body) && isNextWhitespace(firstInverse.Body) {
omitLeftLast(program.Body, false)
omitRightFirst(firstInverse.Body, false)
}
} else if (block.CloseStrip != nil) && block.CloseStrip.Open {
omitLeftLast(program.Body, true)
}
return strip
}
func (v *whitespaceVisitor) VisitMustache(mustache *ast.MustacheStatement) interface{} {
return mustache.Strip
}
func _inlineStandalone(strip *ast.Strip) interface{} {
return &ast.Strip{
Open: strip.Open,
Close: strip.Close,
InlineStandalone: true,
}
}
func (v *whitespaceVisitor) VisitPartial(node *ast.PartialStatement) interface{} {
strip := node.Strip
if strip == nil {
strip = &ast.Strip{}
}
return _inlineStandalone(strip)
}
func (v *whitespaceVisitor) VisitComment(node *ast.CommentStatement) interface{} {
strip := node.Strip
if strip == nil {
strip = &ast.Strip{}
}
return _inlineStandalone(strip)
}
// NOOP
func (v *whitespaceVisitor) VisitContent(node *ast.ContentStatement) interface{} { return nil }
func (v *whitespaceVisitor) VisitExpression(node *ast.Expression) interface{} { return nil }
func (v *whitespaceVisitor) VisitSubExpression(node *ast.SubExpression) interface{} { return nil }
func (v *whitespaceVisitor) VisitPath(node *ast.PathExpression) interface{} { return nil }
func (v *whitespaceVisitor) VisitString(node *ast.StringLiteral) interface{} { return nil }
func (v *whitespaceVisitor) VisitBoolean(node *ast.BooleanLiteral) interface{} { return nil }
func (v *whitespaceVisitor) VisitNumber(node *ast.NumberLiteral) interface{} { return nil }
func (v *whitespaceVisitor) VisitHash(node *ast.Hash) interface{} { return nil }
func (v *whitespaceVisitor) VisitHashPair(node *ast.HashPair) interface{} { return nil }

101
partial.go Normal file
View File

@ -0,0 +1,101 @@
package handlebars
import (
"fmt"
"sync"
)
// partial represents a partial template
type partial struct {
name string
source string
tpl *Template
}
// partials stores all global partials
var partials map[string]*partial
// protects global partials
var partialsMutex sync.RWMutex
func init() {
partials = make(map[string]*partial)
}
// newPartial instantiates a new partial
func newPartial(name string, source string, tpl *Template) *partial {
return &partial{
name: name,
source: source,
tpl: tpl,
}
}
// RegisterPartial registers a global partial. That partial will be available to all templates.
func RegisterPartial(name string, source string) {
partialsMutex.Lock()
defer partialsMutex.Unlock()
if partials[name] != nil {
panic(fmt.Errorf("Partial already registered: %s", name))
}
partials[name] = newPartial(name, source, nil)
}
// RegisterPartials registers several global partials. Those partials will be available to all templates.
func RegisterPartials(partials map[string]string) {
for name, p := range partials {
RegisterPartial(name, p)
}
}
// RegisterPartialTemplate registers a global partial with given parsed template. That partial will be available to all templates.
func RegisterPartialTemplate(name string, tpl *Template) {
partialsMutex.Lock()
defer partialsMutex.Unlock()
if partials[name] != nil {
panic(fmt.Errorf("Partial already registered: %s", name))
}
partials[name] = newPartial(name, "", tpl)
}
// RemovePartial removes the partial registered under the given name. The partial will not be available globally anymore. This does not affect partials registered on a specific template.
func RemovePartial(name string) {
partialsMutex.Lock()
defer partialsMutex.Unlock()
delete(partials, name)
}
// RemoveAllPartials removes all globally registered partials. This does not affect partials registered on a specific template.
func RemoveAllPartials() {
partialsMutex.Lock()
defer partialsMutex.Unlock()
partials = make(map[string]*partial)
}
// findPartial finds a registered global partial
func findPartial(name string) *partial {
partialsMutex.RLock()
defer partialsMutex.RUnlock()
return partials[name]
}
// template returns parsed partial template
func (p *partial) template() (*Template, error) {
if p.tpl == nil {
var err error
p.tpl, err = Parse(p.source)
if err != nil {
return nil, err
}
}
return p.tpl, nil
}

84
string.go Normal file
View File

@ -0,0 +1,84 @@
package handlebars
import (
"fmt"
"reflect"
"strconv"
)
// SafeString represents a string that must not be escaped.
//
// A SafeString can be returned by helpers to disable escaping.
type SafeString string
// isSafeString returns true if argument is a SafeString
func isSafeString(value interface{}) bool {
if _, ok := value.(SafeString); ok {
return true
}
return false
}
// Str returns string representation of any basic type value.
func Str(value interface{}) string {
return strValue(reflect.ValueOf(value))
}
// strValue returns string representation of a reflect.Value
func strValue(value reflect.Value) string {
result := ""
ival, ok := printableValue(value)
if !ok {
panic(fmt.Errorf("Can't print value: %q", value))
}
val := reflect.ValueOf(ival)
switch val.Kind() {
case reflect.Array, reflect.Slice:
for i := 0; i < val.Len(); i++ {
result += strValue(val.Index(i))
}
case reflect.Bool:
result = "false"
if val.Bool() {
result = "true"
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
result = fmt.Sprintf("%d", ival)
case reflect.Float32, reflect.Float64:
result = strconv.FormatFloat(val.Float(), 'f', -1, 64)
case reflect.Invalid:
result = ""
default:
result = fmt.Sprintf("%s", ival)
}
return result
}
// printableValue returns the, possibly indirected, interface value inside v that
// is best for a call to formatted printer.
//
// NOTE: borrowed from https://github.com/golang/go/tree/master/src/text/template/exec.go
func printableValue(v reflect.Value) (interface{}, bool) {
if v.Kind() == reflect.Ptr {
v, _ = indirect(v) // fmt.Fprint handles nil.
}
if !v.IsValid() {
return "", true
}
if !v.Type().Implements(errorType) && !v.Type().Implements(fmtStringerType) {
if v.CanAddr() && (reflect.PtrTo(v.Type()).Implements(errorType) || reflect.PtrTo(v.Type()).Implements(fmtStringerType)) {
v = v.Addr()
} else {
switch v.Kind() {
case reflect.Chan, reflect.Func:
return nil, false
}
}
}
return v.Interface(), true
}

59
string_test.go Normal file
View File

@ -0,0 +1,59 @@
package handlebars
import (
"fmt"
"testing"
)
type strTest struct {
name string
input interface{}
output string
}
var strTests = []strTest{
{"String", "foo", "foo"},
{"Boolean true", true, "true"},
{"Boolean false", false, "false"},
{"Integer", 25, "25"},
{"Float", 25.75, "25.75"},
{"Nil", nil, ""},
{"[]string", []string{"foo", "bar"}, "foobar"},
{"[]interface{} (strings)", []interface{}{"foo", "bar"}, "foobar"},
{"[]Boolean", []bool{true, false}, "truefalse"},
}
func TestStr(t *testing.T) {
t.Parallel()
for _, test := range strTests {
if res := Str(test.input); res != test.output {
t.Errorf("Failed to stringify: %s\nexpected:\n\t'%s'got:\n\t%q", test.name, test.output, res)
}
}
}
func ExampleStr() {
output := Str(3) + " foos are " + Str(true) + " and " + Str(-1.25) + " bars are " + Str(false) + "\n"
output += "But you know '" + Str(nil) + "' John Snow\n"
output += "map: " + Str(map[string]string{"foo": "bar"}) + "\n"
output += "array: " + Str([]interface{}{true, 10, "foo", 5, "bar"})
fmt.Println(output)
// Output: 3 foos are true and -1.25 bars are false
// But you know '' John Snow
// map: map[foo:bar]
// array: true10foo5bar
}
func ExampleSafeString() {
RegisterHelper("em", func() SafeString {
return SafeString("<em>FOO BAR</em>")
})
tpl := MustParse("{{em}}")
result := tpl.MustExec(nil)
fmt.Print(result)
// Output: <em>FOO BAR</em>
}

248
template.go Normal file
View File

@ -0,0 +1,248 @@
package handlebars
import (
"fmt"
"os"
"reflect"
"runtime"
"sync"
"git.reinaldyrafli.com/aldy505/handlebars-go/ast"
"git.reinaldyrafli.com/aldy505/handlebars-go/parser"
)
// Template represents a handlebars template.
type Template struct {
source string
program *ast.Program
helpers map[string]reflect.Value
partials map[string]*partial
mutex sync.RWMutex // protects helpers and partials
}
// newTemplate instanciate a new template without parsing it
func newTemplate(source string) *Template {
return &Template{
source: source,
helpers: make(map[string]reflect.Value),
partials: make(map[string]*partial),
}
}
// Parse instanciates a template by parsing given source.
func Parse(source string) (*Template, error) {
tpl := newTemplate(source)
// parse template
if err := tpl.parse(); err != nil {
return nil, err
}
return tpl, nil
}
// MustParse instanciates a template by parsing given source. It panics on error.
func MustParse(source string) *Template {
result, err := Parse(source)
if err != nil {
panic(err)
}
return result
}
// ParseFile reads given file and returns parsed template.
func ParseFile(filePath string) (*Template, error) {
b, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
return Parse(string(b))
}
// parse parses the template
//
// It can be called several times, the parsing will be done only once.
func (tpl *Template) parse() error {
if tpl.program == nil {
var err error
tpl.program, err = parser.Parse(tpl.source)
if err != nil {
return err
}
}
return nil
}
// Clone returns a copy of that template.
func (tpl *Template) Clone() *Template {
result := newTemplate(tpl.source)
result.program = tpl.program
tpl.mutex.RLock()
defer tpl.mutex.RUnlock()
for name, helper := range tpl.helpers {
result.RegisterHelper(name, helper.Interface())
}
for name, partial := range tpl.partials {
result.addPartial(name, partial.source, partial.tpl)
}
return result
}
func (tpl *Template) findHelper(name string) reflect.Value {
tpl.mutex.RLock()
defer tpl.mutex.RUnlock()
return tpl.helpers[name]
}
// RegisterHelper registers a helper for that template.
func (tpl *Template) RegisterHelper(name string, helper interface{}) {
tpl.mutex.Lock()
defer tpl.mutex.Unlock()
if tpl.helpers[name] != zero {
panic(fmt.Sprintf("Helper %s already registered", name))
}
val := reflect.ValueOf(helper)
ensureValidHelper(name, val)
tpl.helpers[name] = val
}
// RegisterHelpers registers several helpers for that template.
func (tpl *Template) RegisterHelpers(helpers map[string]interface{}) {
for name, helper := range helpers {
tpl.RegisterHelper(name, helper)
}
}
func (tpl *Template) addPartial(name string, source string, template *Template) {
tpl.mutex.Lock()
defer tpl.mutex.Unlock()
if tpl.partials[name] != nil {
panic(fmt.Sprintf("Partial %s already registered", name))
}
tpl.partials[name] = newPartial(name, source, template)
}
func (tpl *Template) findPartial(name string) *partial {
tpl.mutex.RLock()
defer tpl.mutex.RUnlock()
return tpl.partials[name]
}
// RegisterPartial registers a partial for that template.
func (tpl *Template) RegisterPartial(name string, source string) {
tpl.addPartial(name, source, nil)
}
// RegisterPartials registers several partials for that template.
func (tpl *Template) RegisterPartials(partials map[string]string) {
for name, partial := range partials {
tpl.RegisterPartial(name, partial)
}
}
// RegisterPartialFile reads given file and registers its content as a partial with given name.
func (tpl *Template) RegisterPartialFile(filePath string, name string) error {
b, err := os.ReadFile(filePath)
if err != nil {
return err
}
tpl.RegisterPartial(name, string(b))
return nil
}
// RegisterPartialFiles reads several files and registers them as partials, the filename base is used as the partial name.
func (tpl *Template) RegisterPartialFiles(filePaths ...string) error {
if len(filePaths) == 0 {
return nil
}
for _, filePath := range filePaths {
name := fileBase(filePath)
if err := tpl.RegisterPartialFile(filePath, name); err != nil {
return err
}
}
return nil
}
// RegisterPartialTemplate registers an already parsed partial for that template.
func (tpl *Template) RegisterPartialTemplate(name string, template *Template) {
tpl.addPartial(name, "", template)
}
// Exec evaluates template with given context.
func (tpl *Template) Exec(ctx interface{}) (result string, err error) {
return tpl.ExecWith(ctx, nil)
}
// MustExec evaluates template with given context. It panics on error.
func (tpl *Template) MustExec(ctx interface{}) string {
result, err := tpl.Exec(ctx)
if err != nil {
panic(err)
}
return result
}
// ExecWith evaluates template with given context and private data frame.
func (tpl *Template) ExecWith(ctx interface{}, privData *DataFrame) (result string, err error) {
defer errRecover(&err)
// parses template if necessary
err = tpl.parse()
if err != nil {
return
}
// setup visitor
v := newEvalVisitor(tpl, ctx, privData)
// visit AST
result, _ = tpl.program.Accept(v).(string)
// named return values
return
}
// errRecover recovers evaluation panic
func errRecover(errp *error) {
e := recover()
if e != nil {
switch err := e.(type) {
case runtime.Error:
panic(e)
case error:
*errp = err
default:
panic(e)
}
}
}
// PrintAST returns string representation of parsed template.
func (tpl *Template) PrintAST() string {
if err := tpl.parse(); err != nil {
return fmt.Sprintf("PARSER ERROR: %s", err)
}
return ast.Print(tpl.program)
}

166
template_test.go Normal file
View File

@ -0,0 +1,166 @@
package handlebars
import (
"fmt"
"testing"
)
var sourceBasic = `<div class="entry">
<h1>{{title}}</h1>
<div class="body">
{{body}}
</div>
</div>`
var basicAST = `CONTENT[ '<div class="entry">
<h1>' ]
{{ PATH:title [] }}
CONTENT[ '</h1>
<div class="body">
' ]
{{ PATH:body [] }}
CONTENT[ '
</div>
</div>' ]
`
func TestNewTemplate(t *testing.T) {
t.Parallel()
tpl := newTemplate(sourceBasic)
if tpl.source != sourceBasic {
t.Errorf("Failed to instantiate template")
}
}
func TestParse(t *testing.T) {
t.Parallel()
tpl, err := Parse(sourceBasic)
if err != nil || (tpl.source != sourceBasic) {
t.Errorf("Failed to parse template")
}
if str := tpl.PrintAST(); str != basicAST {
t.Errorf("Template parsing incorrect: %s", str)
}
}
func TestClone(t *testing.T) {
t.Parallel()
sourcePartial := `I am a {{wat}} partial`
sourcePartial2 := `Partial for the {{wat}}`
tpl := MustParse(sourceBasic)
tpl.RegisterPartial("p", sourcePartial)
if (len(tpl.partials) != 1) || (tpl.partials["p"] == nil) {
t.Errorf("What?")
}
cloned := tpl.Clone()
if (len(cloned.partials) != 1) || (cloned.partials["p"] == nil) {
t.Errorf("Template partials must be cloned")
}
cloned.RegisterPartial("p2", sourcePartial2)
if (len(cloned.partials) != 2) || (cloned.partials["p"] == nil) || (cloned.partials["p2"] == nil) {
t.Errorf("Failed to register a partial on cloned template")
}
if (len(tpl.partials) != 1) || (tpl.partials["p"] == nil) {
t.Errorf("Modification of a cloned template MUST NOT affect original template")
}
}
func ExampleTemplate_Exec() {
source := "<h1>{{title}}</h1><p>{{body.content}}</p>"
ctx := map[string]interface{}{
"title": "foo",
"body": map[string]string{"content": "bar"},
}
// parse template
tpl := MustParse(source)
// evaluate template with context
output, err := tpl.Exec(ctx)
if err != nil {
panic(err)
}
fmt.Print(output)
// Output: <h1>foo</h1><p>bar</p>
}
func ExampleTemplate_MustExec() {
source := "<h1>{{title}}</h1><p>{{body.content}}</p>"
ctx := map[string]interface{}{
"title": "foo",
"body": map[string]string{"content": "bar"},
}
// parse template
tpl := MustParse(source)
// evaluate template with context
output := tpl.MustExec(ctx)
fmt.Print(output)
// Output: <h1>foo</h1><p>bar</p>
}
func ExampleTemplate_ExecWith() {
source := "<h1>{{title}}</h1><p>{{#body}}{{content}} and {{@baz.bat}}{{/body}}</p>"
ctx := map[string]interface{}{
"title": "foo",
"body": map[string]string{"content": "bar"},
}
// parse template
tpl := MustParse(source)
// computes private data frame
frame := NewDataFrame()
frame.Set("baz", map[string]string{"bat": "unicorns"})
// evaluate template
output, err := tpl.ExecWith(ctx, frame)
if err != nil {
panic(err)
}
fmt.Print(output)
// Output: <h1>foo</h1><p>bar and unicorns</p>
}
func ExampleTemplate_PrintAST() {
source := "<h1>{{title}}</h1><p>{{#body}}{{content}} and {{@baz.bat}}{{/body}}</p>"
// parse template
tpl := MustParse(source)
// print AST
output := tpl.PrintAST()
fmt.Print(output)
// Output: CONTENT[ '<h1>' ]
// {{ PATH:title [] }}
// CONTENT[ '</h1><p>' ]
// BLOCK:
// PATH:body []
// PROGRAM:
// {{ PATH:content []
// }}
// CONTENT[ ' and ' ]
// {{ @PATH:baz/bat []
// }}
// CONTENT[ '</p>' ]
//
}

85
utils.go Normal file
View File

@ -0,0 +1,85 @@
package handlebars
import (
"path"
"reflect"
)
// indirect returns the item at the end of indirection, and a bool to indicate if it's nil.
// We indirect through pointers and empty interfaces (only) because
// non-empty interfaces have methods we might need.
//
// NOTE: borrowed from https://github.com/golang/go/tree/master/src/text/template/exec.go
func indirect(v reflect.Value) (rv reflect.Value, isNil bool) {
for ; v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface; v = v.Elem() {
if v.IsNil() {
return v, true
}
if v.Kind() == reflect.Interface && v.NumMethod() > 0 {
break
}
}
return v, false
}
// IsTrue returns true if obj is a truthy value.
func IsTrue(obj interface{}) bool {
thruth, ok := isTrueValue(reflect.ValueOf(obj))
if !ok {
return false
}
return thruth
}
// isTrueValue reports whether the value is 'true', in the sense of not the zero of its type,
// and whether the value has a meaningful truth value
//
// NOTE: borrowed from https://github.com/golang/go/tree/master/src/text/template/exec.go
func isTrueValue(val reflect.Value) (truth, ok bool) {
if !val.IsValid() {
// Something like var x interface{}, never set. It's a form of nil.
return false, true
}
switch val.Kind() {
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
truth = val.Len() > 0
case reflect.Bool:
truth = val.Bool()
case reflect.Complex64, reflect.Complex128:
truth = val.Complex() != 0
case reflect.Chan, reflect.Func, reflect.Ptr, reflect.Interface:
truth = !val.IsNil()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
truth = val.Int() != 0
case reflect.Float32, reflect.Float64:
truth = val.Float() != 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
truth = val.Uint() != 0
case reflect.Struct:
truth = true // Struct values are always true.
default:
return
}
return truth, true
}
// canBeNil reports whether an untyped nil can be assigned to the type. See reflect.Zero.
//
// NOTE: borrowed from https://github.com/golang/go/tree/master/src/text/template/exec.go
func canBeNil(typ reflect.Type) bool {
switch typ.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return true
}
return false
}
// fileBase returns base file name
//
// example: /foo/bar/baz.png => baz
func fileBase(filePath string) string {
fileName := path.Base(filePath)
fileExt := path.Ext(filePath)
return fileName[:len(fileName)-len(fileExt)]
}

51
utils_test.go Normal file
View File

@ -0,0 +1,51 @@
package handlebars
import "fmt"
func ExampleIsTrue() {
output := "Empty array: " + Str(IsTrue([0]string{})) + "\n"
output += "Non empty array: " + Str(IsTrue([1]string{"foo"})) + "\n"
output += "Empty slice: " + Str(IsTrue([]string{})) + "\n"
output += "Non empty slice: " + Str(IsTrue([]string{"foo"})) + "\n"
output += "Empty map: " + Str(IsTrue(map[string]string{})) + "\n"
output += "Non empty map: " + Str(IsTrue(map[string]string{"foo": "bar"})) + "\n"
output += "Empty string: " + Str(IsTrue("")) + "\n"
output += "Non empty string: " + Str(IsTrue("foo")) + "\n"
output += "true bool: " + Str(IsTrue(true)) + "\n"
output += "false bool: " + Str(IsTrue(false)) + "\n"
output += "0 integer: " + Str(IsTrue(0)) + "\n"
output += "positive integer: " + Str(IsTrue(10)) + "\n"
output += "negative integer: " + Str(IsTrue(-10)) + "\n"
output += "0 float: " + Str(IsTrue(0.0)) + "\n"
output += "positive float: " + Str(IsTrue(10.0)) + "\n"
output += "negative integer: " + Str(IsTrue(-10.0)) + "\n"
output += "struct: " + Str(IsTrue(struct{}{})) + "\n"
output += "nil: " + Str(IsTrue(nil)) + "\n"
fmt.Println(output)
// Output: Empty array: false
// Non empty array: true
// Empty slice: false
// Non empty slice: true
// Empty map: false
// Non empty map: true
// Empty string: false
// Non empty string: true
// true bool: true
// false bool: false
// 0 integer: false
// positive integer: true
// negative integer: true
// 0 float: false
// positive float: true
// negative integer: true
// struct: true
// nil: false
}

21
virtualmethods.go Normal file
View File

@ -0,0 +1,21 @@
package handlebars
import "reflect"
type virtualMethod func(ctx reflect.Value) (val reflect.Value, hasMethod bool)
func getVirtualMethod(name string) virtualMethod {
switch name {
case "length":
return vmethodLength
}
return nil
}
func vmethodLength(ctx reflect.Value) (reflect.Value, bool) {
switch ctx.Kind() {
case reflect.Slice, reflect.Array, reflect.String, reflect.Map:
return reflect.ValueOf(ctx.Len()), true
}
return zero, false
}