commit 8072514c6f03aadde60736b31491c950645f9627 Author: Reinaldy Rafli Date: Sun Sep 10 17:55:27 2023 +0700 feat: initialize fork diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a757998 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "mustache"] + path = mustache + url = git://github.com/mustache/spec.git diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1cc4695 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..43a1672 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +The MIT License (MIT) + +Copyright (c) 2023 Reinaldy Rafli +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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f18fcfb --- /dev/null +++ b/README.md @@ -0,0 +1,1428 @@ +# handlebars +[![Go Reference](https://pkg.go.dev/badge/github.com/flowchartsman/handlebars.svg)](https://pkg.go.dev/git.reinaldyrafli.com/aldy505/handlebars-go) + +This is yet another fork of [github.com/flowchartsman/handlebars](https://github.com/flowchartsman/handlebars). + +Handlebars for [golang](https://golang.org) with the same features as [handlebars.js](http://handlebarsjs.com) `3.0`. Hard fork of [Raymond](https://github.com/aymerick/raymond) to modularize and keep up with handlebars development. + +![Handlebars Logo](https://github.com/flowchartsman/handlebars/blob/main/handlebars-gopher.png?raw=true "Handlebars") + + +# Table of Contents + +- [Quick Start](#quick-start) +- [Correct Usage](#correct-usage) +- [Context](#context) +- [HTML Escaping](#html-escaping) +- [Helpers](#helpers) + - [Template Helpers](#template-helpers) + - [Built-In Helpers](#built-in-helpers) + - [The `if` block helper](#the-if-block-helper) + - [The `unless` block helper](#the-unless-block-helper) + - [The `each` block helper](#the-each-block-helper) + - [The `with` block helper](#the-with-block-helper) + - [The `lookup` helper](#the-lookup-helper) + - [The `log` helper](#the-log-helper) + - [The `equal` helper](#the-equal-helper) + - [Block Helpers](#block-helpers) + - [Block Evaluation](#block-evaluation) + - [Conditional](#conditional) + - [Else Block Evaluation](#else-block-evaluation) + - [Block Parameters](#block-parameters) + - [Helper Parameters](#helper-parameters) + - [Automatic conversion](#automatic-conversion) + - [Options Argument](#options-argument) + - [Context Values](#context-values) + - [Helper Hash Arguments](#helper-hash-arguments) + - [Private Data](#private-data) + - [Utilites](#utilites) + - [`Str()`](#str) + - [`IsTrue()`](#istrue) +- [Context Functions](#context-functions) +- [Partials](#partials) + - [Template Partials](#template-partials) + - [Global Partials](#global-partials) + - [Dynamic Partials](#dynamic-partials) + - [Partial Contexts](#partial-contexts) + - [Partial Parameters](#partial-parameters) +- [Utility Functions](#utility-functions) +- [Mustache](#mustache) +- [Limitations](#limitations) +- [Handlebars Lexer](#handlebars-lexer) +- [Handlebars Parser](#handlebars-parser) +- [Test](#test) +- [References](#references) +- [Others Implementations](#others-implementations) + + +## Quick Start + + $ go get git.reinaldyrafli.com/aldy505/handlebars-go + +The quick and dirty way of rendering a handlebars template: + +```go +package main + +import ( + "fmt" + + "git.reinaldyrafli.com/aldy505/handlebars-go" +) + +func main() { + tpl := `
+

{{title}}

+
+ {{body}} +
+
+` + + ctx := map[string]string{ + "title": "My New Post", + "body": "This is my first post!", + } + + result, err := handlebars.Render(tpl, ctx) + if err != nil { + panic("Please report a bug :)") + } + + fmt.Print(result) +} +``` + +Displays: + +```html +
+

My New Post

+
+ This is my first post! +
+
+``` + +Please note that the template will be parsed everytime you call `Render()` function. So you probably want to read the next section. + + +## Correct Usage + +To avoid parsing a template several times, use the `Parse()` and `Exec()` functions: + +```go +package main + +import ( + "fmt" + + "git.reinaldyrafli.com/aldy505/handlebars-go +) + +func main() { + source := `
+

{{title}}

+
+ {{body}} +
+
+` + + ctxList := []map[string]string{ + { + "title": "My New Post", + "body": "This is my first post!", + }, + { + "title": "Here is another post", + "body": "This is my second post!", + }, + } + + // parse template + tpl, err := handlebars.Parse(source) + if err != nil { + panic(err) + } + + for _, ctx := range ctxList { + // render template + result, err := tpl.Exec(ctx) + if err != nil { + panic(err) + } + + fmt.Print(result) + } +} + +``` + +Displays: + +```html +
+

My New Post

+
+ This is my first post! +
+
+
+

Here is another post

+
+ This is my second post! +
+
+``` + +You can use `MustParse()` and `MustExec()` functions if you don't want to deal with errors: + +```go +// parse template +tpl := handlebars.MustParse(source) + +// render template +result := tpl.MustExec(ctx) +``` + + +## Context + +The rendering context can contain any type of values, including `array`, `slice`, `map`, `struct` and `func`. + +When using structs, be warned that only exported fields are accessible. However you can access exported fields in template with their lowercase names. For example, both `{{author.firstName}}` and `{{Author.FirstName}}` references give the same result, as long as `Author` and `FirstName` are exported struct fields. + +More, you can use the `handlebars` struct tag to specify a template variable name different from the struct field name. + +```go +package main + +import ( + "fmt" + + "git.reinaldyrafli.com/aldy505/handlebars-go +) + +func main() { + source := `
+

By {{author.firstName}} {{author.lastName}}

+
{{body}}
+ +

Comments

+ + {{#each comments}} +

By {{author.firstName}} {{author.lastName}}

+
{{content}}
+ {{/each}} +
` + + 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{ + Comment{ + Person{"Marcel", "Beliveau"}, + "LOL!", + }, + }, + } + + output := handlebars.MustRender(source, ctx) + + fmt.Print(output) +} +``` + +Output: + +```html +
+

By Jean Valjean

+
Life is difficult
+ +

Comments

+ +

By Marcel Beliveau

+
LOL!
+
+``` + +## HTML Escaping + +By default, the result of a mustache expression is HTML escaped. Use the triple mustache `{{{` to output unescaped values. + +```go +source := `
+

{{title}}

+
+ {{{body}}} +
+
+` + +ctx := map[string]string{ + "title": "All about

Tags", + "body": "

This is a post about <p> tags

", +} + +tpl := handlebars.MustParse(source) +result := tpl.MustExec(ctx) + +fmt.Print(result) +``` + +Output: + +```html +
+

All about <p> Tags

+
+

This is a post about <p> tags

+
+
+``` + +When returning HTML from a helper, you should return a `SafeString` if you don't want it to be escaped by default. When using `SafeString` all unknown or unsafe data should be manually escaped with the `Escape` method. + +```go +handlebars.RegisterHelper("link", func(url, text string) handlebars.SafeString { + return handlebars.SafeString("" + handlebars.Escape(text) + "") +}) + +tpl := handlebars.MustParse("{{link url text}}") + +ctx := map[string]string{ + "url": "http://www.aymerick.com/", + "text": "This is a cool website", +} + +result := tpl.MustExec(ctx) +fmt.Print(result) +``` + +Output: + +```html +This is a <em>cool</em> website +``` + + +## Helpers + +Helpers can be accessed from any context in a template. You can register a helper with the `RegisterHelper` function. + +For example: + +```html +
+

By {{fullName author}}

+
{{body}}
+ +

Comments

+ + {{#each comments}} +

By {{fullName author}}

+
{{body}}
+ {{/each}} +
+``` + +With this context and helper: + +```go +ctx := map[string]interface{}{ + "author": map[string]string{"firstName": "Jean", "lastName": "Valjean"}, + "body": "Life is difficult", + "comments": []map[string]interface{}{{ + "author": map[string]string{"firstName": "Marcel", "lastName": "Beliveau"}, + "body": "LOL!", + }}, +} + +handlebars.RegisterHelper("fullName", func(person map[string]string) string { + return person["firstName"] + " " + person["lastName"] +}) +``` + +Outputs: + +```html +
+

By Jean Valjean

+
Life is difficult
+ +

Comments

+ +

By Marcel Beliveau

+
LOL!
+
+``` + +Helper arguments can be any type. + +The following example uses structs instead of maps and produces the same output as the previous one: + +```html +
+

By {{fullName author}}

+
{{body}}
+ +

Comments

+ + {{#each comments}} +

By {{fullName author}}

+
{{body}}
+ {{/each}} +
+``` + +With this context and helper: + +```go +type Post struct { + Author Person + Body string + Comments []Comment +} + +type Person struct { + FirstName string + LastName string +} + +type Comment struct { + Author Person + Body string +} + +ctx := Post{ + Person{"Jean", "Valjean"}, + "Life is difficult", + []Comment{ + Comment{ + Person{"Marcel", "Beliveau"}, + "LOL!", + }, + }, +} + +handlebars.RegisterHelper("fullName", func(person Person) string { + return person.FirstName + " " + person.LastName +}) +``` + +You can unregister global helpers with `RemoveHelper` and `RemoveAllHelpers` functions: + +```go +handlebars.RemoveHelper("fullname") +``` + +```go +handlebars.RemoveAllHelpers() +``` + + +### Template Helpers + +You can register a helper on a specific template, and in that case that helper will be available to that template only: + +```go +tpl := handlebars.MustParse("User: {{fullName user.firstName user.lastName}}") + +tpl.RegisterHelper("fullName", func(firstName, lastName string) string { + return firstName + " " + lastName +}) +``` + + +### Built-In Helpers + +Those built-in helpers are available to all templates. + + +#### The `if` block helper + +You can use the `if` helper to conditionally render a block. If its argument returns `false`, `nil`, `0`, `""`, an empty array, an empty slice or an empty map, then handlebars will not render the block. + +```html +
+ {{#if author}} +

{{firstName}} {{lastName}}

+ {{/if}} +
+``` + +When using a block expression, you can specify a template section to run if the expression returns a falsy value. That section, marked by `{{else}}` is called an "else section". + +```html +
+ {{#if author}} +

{{firstName}} {{lastName}}

+ {{else}} +

Unknown Author

+ {{/if}} +
+``` + +You can chain several blocks. For example that template: + +```html +{{#if isActive}} + Active +{{else if isInactive}} + Inactive +{{else}} + Unknown +{{/if}} +``` + +With that context: + +```go +ctx := map[string]interface{}{ + "isActive": false, + "isInactive": false, +} +``` + +Outputs: + +```html + Unknown +``` + + +#### The `unless` block helper + +You can use the `unless` helper as the inverse of the `if` helper. Its block will be rendered if the expression returns a falsy value. + +```html +
+ {{#unless license}} +

WARNING: This entry does not have a license!

+ {{/unless}} +
+``` + + +#### The `each` block helper + +You can iterate over an array, a slice, a map or a struct instance using this built-in `each` helper. Inside the block, you can use `this` to reference the element being iterated over. + +For example: + +```html + +``` + +With this context: + +```go +map[string]interface{}{ + "people": []string{ + "Marcel", "Jean-Claude", "Yvette", + }, +} +``` + +Outputs: + +```html + +``` + +You can optionally provide an `{{else}}` section which will display only when the passed argument is an empty array, an empty slice or an empty map (a `struct` instance is never considered empty). + +```html +{{#each paragraphs}} +

{{this}}

+{{else}} +

No content

+{{/each}} +``` + +When looping through items in `each`, you can optionally reference the current loop index via `{{@index}}`. + +```html +{{#each array}} + {{@index}}: {{this}} +{{/each}} +``` + +Additionally for map and struct instance iteration, `{{@key}}` references the current map key or struct field name: + +```html +{{#each map}} + {{@key}}: {{this}} +{{/each}} +``` + +The first and last steps of iteration are noted via the `@first` and `@last` variables. + + +#### The `with` block helper + +You can shift the context for a section of a template by using the built-in `with` block helper. + +```html +
+

{{title}}

+ + {{#with author}} +

By {{firstName}} {{lastName}}

+ {{/with}} +
+``` + +With this context: + +```go +map[string]interface{}{ + "title": "My first post!", + "author": map[string]string{ + "firstName": "Jean", + "lastName": "Valjean", + }, +} +``` + +Outputs: + +```html +
+

My first post!

+ +

By Jean Valjean

+
+``` + +You can optionally provide an `{{else}}` section which will display only when the passed argument is falsy. + +```html +{{#with author}} +

{{name}}

+{{else}} +

No content

+{{/with}} +``` + + +#### The `lookup` helper + +The `lookup` helper allows for dynamic parameter resolution using handlebars variables. + +```html +{{#each bar}} + {{lookup ../foo @index}} +{{/each}} +``` + + +#### The `log` helper + +The `log` helper allows for logging while rendering a template. + +```html +{{log "Look at me!"}} +``` + +Note that the handlebars.js `@level` variable is not supported. + + +#### The `equal` helper + +The `equal` helper renders a block if the string version of both arguments are equals. + +For example that template: + +```html +{{#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}} +``` + +With that context: + +```go +ctx := map[string]interface{}{ + "foo": "bar", + "baz": "bar", + "nb": 1, +} +``` + +Outputs: + +```html +foo is bar +foo is the same as baz + +there is one +everything is stringified before comparison +``` + + +### Block Helpers + +Block helpers make it possible to define custom iterators and other functionality that can invoke the passed block with a new context. + + +#### Block Evaluation + +As an example, let's define a block helper that adds some markup to the wrapped text. + +```html +
+

{{title}}

+
+ {{#bold}}{{body}}{{/bold}} +
+
+``` + +The `bold` helper will add markup to make its text bold. + +```go +handlebars.RegisterHelper("bold", func(options *handlebars.Options) handlebars.SafeString { + return handlebars.SafeString(`
` + options.Fn() + "
") +}) +``` + +A helper evaluates the block content with current context by calling `options.Fn()`. + +If you want to evaluate the block with another context, then use `options.FnWith(ctx)`, like this french version of built-in `with` block helper: + +```go +handlebars.RegisterHelper("avec", func(context interface{}, options *handlebars.Options) string { + return options.FnWith(context) +}) +``` + +With that template: + +```html +{{#avec obj.text}}{{this}}{{/avec}} +``` + + +#### Conditional + +Let's write a french version of `if` block helper: + +```go +source := `{{#si yep}}YEP !{{/si}}` + +ctx := map[string]interface{}{"yep": true} + +handlebars.RegisterHelper("si", func(conditional bool, options *handlebars.Options) string { + if conditional { + return options.Fn() + } + return "" +}) +``` + +Note that as the first parameter of the helper is typed as `bool` an automatic conversion is made if corresponding context value is not a boolean. So this helper works with that context too: + +```go +ctx := map[string]interface{}{"yep": "message"} +``` + +Here, `"message"` is converted to `true` because it is an non-empty string. See `IsTrue()` function for more informations on boolean conversion. + + +#### Else Block Evaluation + +We can enhance the `si` block helper to evaluate the `else block` by calling `options.Inverse()` if conditional is false: + +```go +source := `{{#si yep}}YEP !{{else}}NOP !{{/si}}` + +ctx := map[string]interface{}{"yep": false} + +handlebars.RegisterHelper("si", func(conditional bool, options *handlebars.Options) string { + if conditional { + return options.Fn() + } + return options.Inverse() +}) +``` + +Outputs: +``` +NOP ! +``` + + +#### Block Parameters + +It's possible to receive named parameters from supporting helpers. + +```html +{{#each users as |user userId|}} + Id: {{userId}} Name: {{user.name}} +{{/each}} +``` + +In this particular example, `user` will have the same value as the current context and `userId` will have the index/key value for the iteration. + +This allows for nested helpers to avoid name conflicts. + +For example: + +```html +{{#each users as |user userId|}} + {{#each user.books as |book bookId|}} + User: {{userId}} Book: {{bookId}} + {{/each}} +{{/each}} +``` + +With this context: + +```go +ctx := map[string]interface{}{ + "users": map[string]interface{}{ + "marcel": map[string]interface{}{ + "books": map[string]interface{}{ + "book1": "My first book", + "book2": "My second book", + }, + }, + "didier": map[string]interface{}{ + "books": map[string]interface{}{ + "bookA": "Good book", + "bookB": "Bad book", + }, + }, + }, +} +``` + +Outputs: + +```html + User: marcel Book: book1 + User: marcel Book: book2 + User: didier Book: bookA + User: didier Book: bookB +``` + +As you can see, the second block parameter is the map key. When using structs, it is the struct field name. + +When using arrays and slices, the second parameter is element index: + +```go +ctx := map[string]interface{}{ + "users": []map[string]interface{}{ + { + "id": "marcel", + "books": []map[string]interface{}{ + {"id": "book1", "title": "My first book"}, + {"id": "book2", "title": "My second book"}, + }, + }, + { + "id": "didier", + "books": []map[string]interface{}{ + {"id": "bookA", "title": "Good book"}, + {"id": "bookB", "title": "Bad book"}, + }, + }, + }, +} +``` + +Outputs: + +```html + User: 0 Book: 0 + User: 0 Book: 1 + User: 1 Book: 0 + User: 1 Book: 1 +``` + + +### Helper Parameters + +When calling a helper in a template, handlebars expects the same number of arguments as the number of helper function parameters. + +So this template: + +```html +{{add a}} +``` + +With this helper: + +```go +handlebars.RegisterHelper("add", func(val1, val2 int) string { + return strconv.Itoa(val1 + val2) +}) +``` + +Will simply panics, because we call the helper with one argument whereas it expects two. + + +#### Automatic conversion + +Let's create a `concat` helper that expects two strings and concat them: + +```go +source := `{{concat a b}}` + +ctx := map[string]interface{}{ + "a": "Jean", + "b": "Valjean", +} + +handlebars.RegisterHelper("concat", func(val1, val2 string) string { + return val1 + " " + val2 +}) +``` + +Everything goes well, two strings are passed as arguments to the helper that outputs: + +```html +Jean VALJEAN +``` + +But what happens if there is another type than `string` in the context ? For example: + +```go +ctx := map[string]interface{}{ + "a": 10, + "b": "Valjean", +} +``` + +Actually, handlebars perfoms automatic string conversion. So because the first parameter of the helper is typed as `string`, the first argument will be converted from the `10` integer to `"10"`, and the helper outputs: + +```html +10 VALJEAN +``` + +Note that this kind of automatic conversion is done with `bool` type too, thanks to the `IsTrue()` function. + + +### Options Argument + +If a helper needs the `Options` argument, just add it at the end of helper parameters: + +```go +handlebars.RegisterHelper("add", func(val1, val2 int, options *handlebars.Options) string { + return strconv.Itoa(val1 + val2) + " " + options.ValueStr("bananas") +}) +``` + +Thanks to the `options` argument, helpers have access to the current evaluation context, to the `Hash` arguments, and they can manipulate the private data variables. + +The `Options` argument is even necessary for Block Helpers to evaluate block and "else block". + + +#### Context Values + +Helpers fetch current context values with `options.Value()` and `options.ValuesStr()`. + +`Value()` returns an `interface{}` and lets the helper do the type assertions whereas `ValueStr()` automatically converts the value to a `string`. + +For example: + +```go +source := `{{concat a b}}` + +ctx := map[string]interface{}{ + "a": "Marcel", + "b": "Beliveau", + "suffix": "FOREVER !", +} + +handlebars.RegisterHelper("concat", func(val1, val2 string, options *handlebars.Options) string { + return val1 + " " + val2 + " " + options.ValueStr("suffix") +}) +``` + +Outputs: + +```html +Marcel Beliveau FOREVER ! +``` + +Helpers can get the entire current context with `options.Ctx()` that returns an `interface{}`. + + +#### Helper Hash Arguments + +Helpers access hash arguments with `options.HashProp()` and `options.HashStr()`. + +`HashProp()` returns an `interface{}` and lets the helper do the type assertions whereas `HashStr()` automatically converts the value to a `string`. + +For example: + +```go +source := `{{concat suffix first=a second=b}}` + +ctx := map[string]interface{}{ + "a": "Marcel", + "b": "Beliveau", + "suffix": "FOREVER !", +} + +handlebars.RegisterHelper("concat", func(suffix string, options *handlebars.Options) string { + return options.HashStr("first") + " " + options.HashStr("second") + " " + suffix +}) +``` + +Outputs: + +```html +Marcel Beliveau FOREVER ! +``` + +Helpers can get the full hash with `options.Hash()` that returns a `map[string]interface{}`. + + +#### Private Data + +Helpers access private data variables with `options.Data()` and `options.DataStr()`. + +`Data()` returns an `interface{}` and lets the helper do the type assertions whereas `DataStr()` automatically converts the value to a `string`. + +Helpers can get the entire current data frame with `options.DataFrame()` that returns a `*DataFrame`. + +For helpers that need to inject their own private data frame, use `options.NewDataFrame()` to create the frame and `options.FnData()` to evaluate the block with that frame. + +For example: + +```go +source := `{{#voodoo kind=a}}Voodoo is {{@magix}}{{/voodoo}}` + +ctx := map[string]interface{}{ + "a": "awesome", +} + +handlebars.RegisterHelper("voodoo", func(options *handlebars.Options) string { + // create data frame with @magix data + frame := options.NewDataFrame() + frame.Set("magix", options.HashProp("kind")) + + // evaluates block with new data frame + return options.FnData(frame) +}) +``` + +Helpers that need to evaluate the block with a private data frame and a new context can call `options.FnCtxData()`. + + +### Utilites + +In addition to `Escape()`, handlebars provides utility functions that can be usefull for helpers. + + +#### `Str()` + +`Str()` converts its parameter to a `string`. + +Booleans: + +```go +handlebars.Str(3) + " foos and " + handlebars.Str(-1.25) + " bars" +// Outputs: "3 foos and -1.25 bars" +``` + +Numbers: + +``` go +"everything is " + handlebars.Str(true) + " and nothing is " + handlebars.Str(false) +// Outputs: "everything is true and nothing is false" +``` + +Maps: + +```go +handlebars.Str(map[string]string{"foo": "bar"}) +// Outputs: "map[foo:bar]" +``` + +Arrays and Slices: + +```go +handlebars.Str([]interface{}{true, 10, "foo", 5, "bar"}) +// Outputs: "true10foo5bar" +``` + + +#### `IsTrue()` + +`IsTrue()` returns the truthy version of its parameter. + +It returns `false` when parameter is either: + + - an empty array + - an empty slice + - an empty map + - `""` + - `nil` + - `0` + - `false` + +For all others values, `IsTrue()` returns `true`. + + +## Context Functions + +In addition to helpers, lambdas found in context are evaluated. + +For example, that template and context: + +```go +source := "I {{feeling}} you" + +ctx := map[string]interface{}{ + "feeling": func() string { + rand.Seed(time.Now().UTC().UnixNano()) + + feelings := []string{"hate", "love"} + return feelings[rand.Intn(len(feelings))] + }, +} +``` + +Randomly renders `I hate you` or `I love you`. + +Those context functions behave like helper functions: they can be called with parameters and they can have an `Options` argument. + + +## Partials + +### Template Partials + +You can register template partials before execution: + +```go +tpl := handlebars.MustParse("{{> foo}} baz") +tpl.RegisterPartial("foo", "bar") + +result := tpl.MustExec(nil) +fmt.Print(result) +``` + +Output: + +```html +bar baz +``` + +You can register several partials at once: + +```go +tpl := handlebars.MustParse("{{> foo}} and {{> baz}}") +tpl.RegisterPartials(map[string]string{ + "foo": "bar", + "baz": "bat", +}) + +result := tpl.MustExec(nil) +fmt.Print(result) +``` + +Output: + +```html +bar and bat +``` + + +### Global Partials + +You can registers global partials that will be accessible by all templates: + +```go +handlebars.RegisterPartial("foo", "bar") + +tpl := handlebars.MustParse("{{> foo}} baz") +result := tpl.MustExec(nil) +fmt.Print(result) +``` + +Or: + +```go +handlebars.RegisterPartials(map[string]string{ + "foo": "bar", + "baz": "bat", +}) + +tpl := handlebars.MustParse("{{> foo}} and {{> baz}}") +result := tpl.MustExec(nil) +fmt.Print(result) +``` + + +### Dynamic Partials + +It's possible to dynamically select the partial to be executed by using sub expression syntax. + +For example, that template randomly evaluates the `foo` or `baz` partial: + +```go +tpl := handlebars.MustParse("{{> (whichPartial) }}") +tpl.RegisterPartials(map[string]string{ + "foo": "bar", + "baz": "bat", +}) + +ctx := map[string]interface{}{ + "whichPartial": func() string { + rand.Seed(time.Now().UTC().UnixNano()) + + names := []string{"foo", "baz"} + return names[rand.Intn(len(names))] + }, +} + +result := tpl.MustExec(ctx) +fmt.Print(result) +``` + + +### Partial Contexts + +It's possible to execute partials on a custom context by passing in the context to the partial call. + +For example: + +```go +tpl := handlebars.MustParse("User: {{> userDetails user }}") +tpl.RegisterPartial("userDetails", "{{firstname}} {{lastname}}") + +ctx := map[string]interface{}{ + "user": map[string]string{ + "firstname": "Jean", + "lastname": "Valjean", + }, +} + +result := tpl.MustExec(ctx) +fmt.Print(result) +``` + +Displays: + +```html +User: Jean Valjean +``` + + +### Partial Parameters + +Custom data can be passed to partials through hash parameters. + +For example: + +```go +tpl := handlebars.MustParse("{{> myPartial name=hero }}") +tpl.RegisterPartial("myPartial", "My hero is {{name}}") + +ctx := map[string]interface{}{ + "hero": "Goldorak", +} + +result := tpl.MustExec(ctx) +fmt.Print(result) +``` + +Displays: + +```html +My hero is Goldorak +``` + + +## Utility Functions + +You can use following utility fuctions to parse and register partials from files: + +- `ParseFile()` - reads a file and return parsed template +- `Template.RegisterPartialFile()` - reads a file and registers its content as a partial with given name +- `Template.RegisterPartialFiles()` - reads several files and registers them as partials, the filename base is used as the partial name + + +## Mustache + +Handlebars is a superset of [mustache](https://mustache.github.io) but it differs on those points: + +- Alternative delimiters are not supported +- There is no recursive lookup + + +## Limitations + +These handlebars options are currently NOT implemented: + +- `compat` - enables recursive field lookup +- `knownHelpers` - list of helpers that are known to exist (truthy) at template execution time +- `knownHelpersOnly` - allows further optimizations based on the known helpers list +- `trackIds` - include the id names used to resolve parameters for helpers +- `noEscape` - disables HTML escaping globally +- `strict` - templates will throw rather than silently ignore missing fields +- `assumeObjects` - removes object existence checks when traversing paths +- `preventIndent` - disables the auto-indententation of nested partials +- `stringParams` - resolves a parameter to it's name if the value isn't present in the context stack + +These handlebars features are currently NOT implemented: + +- raw block content is not passed as a parameter to helper +- `blockHelperMissing` - helper called when a helper can not be directly resolved +- `helperMissing` - helper called when a potential helper expression was not found +- `@contextPath` - value set in `trackIds` mode that records the lookup path for the current context +- `@level` - log level + + +## Handlebars Lexer + +You should not use the lexer directly, but for your information here is an example: + +```go +package main + +import ( + "fmt" + + "git.reinaldyrafli.com/aldy505/handlebars-go/lexer" +) + +func main() { + source := "You know {{nothing}} John Snow" + + output := "" + + lex := lexer.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 == lexer.TokenEOF || token.Kind == lexer.TokenError { + break + } + } + + fmt.Print(output) +} +``` + +Outputs: + +``` +Content{"You know "} Open{"{{"} ID{"nothing"} Close{"}}"} Content{" John Snow"} EOF +``` + + +## Handlebars Parser + +You should not use the parser directly, but for your information here is an example: + +```go +package main + +import ( + "fmt" + + "git.reinaldyrafli.com/aldy505/handlebars-go/ast" + "git.reinaldyrafli.com/aldy505/handlebars-go/parser" +) + +fu nc main() { + source := "You know {{nothing}} John Snow" + + // parse template + program, err := parser.Parse(source) + if err != nil { + panic(err) + } + + // print AST + output := ast.Print(program) + + fmt.Print(output) +} +``` + +Outputs: + +``` +CONTENT[ 'You know ' ] +{{ PATH:nothing [] }} +CONTENT[ ' John Snow' ] +``` + + +## Test + +First, fetch mustache tests: + + $ git submodule update --init + +To run all tests: + + $ go test ./... + +To filter tests: + + $ go test -run="Partials" + +To run all test and all benchmarks: + + $ go test -bench . ./... + +To test with race detection: + + $ go test -race ./... + + +## References + + - + - + - + - + + +## Others Implementations + +- [handlebars.js](http://handlebarsjs.com) - javascript +- [handlebars.java](https://github.com/jknack/handlebars.java) - java +- [handlebars.rb](https://github.com/cowboyd/handlebars.rb) - ruby +- [handlebars.php](https://github.com/XaminProject/handlebars.php) - php +- [handlebars-objc](https://github.com/Bertrand/handlebars-objc) - Objective C +- [rumblebars](https://github.com/nicolas-cherel/rumblebars) - rust diff --git a/ast/node.go b/ast/node.go new file mode 100644 index 0000000..aaef066 --- /dev/null +++ b/ast/node.go @@ -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) +} diff --git a/ast/print.go b/ast/print.go new file mode 100644 index 0000000..133ae6e --- /dev/null +++ b/ast/print.go @@ -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 +} diff --git a/base_test.go b/base_test.go new file mode 100644 index 0000000..56ad48a --- /dev/null +++ b/base_test.go @@ -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) + } + } + } + } +} diff --git a/benchmark_test.go b/benchmark_test.go new file mode 100644 index 0000000..f3b2231 --- /dev/null +++ b/benchmark_test.go @@ -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 := `

{{header}}

+{{#if items}} +
    + {{#each items}} + {{#if current}} +
  • {{name}}
  • + {{^}} +
  • {{name}}
  • + {{/if}} + {{/each}} +
+{{^}} +

The list is empty.

+{{/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) + } +} diff --git a/data_frame.go b/data_frame.go new file mode 100644 index 0000000..1a92863 --- /dev/null +++ b/data_frame.go @@ -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 +} diff --git a/escape.go b/escape.go new file mode 100644 index 0000000..3fbc133 --- /dev/null +++ b/escape.go @@ -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: +// ' => ' +// " => " +// +// 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 = "&" + case '\'': + esc = "'" + case '<': + esc = "<" + case '>': + esc = ">" + case '"': + esc = """ + 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() +} diff --git a/escape_test.go b/escape_test.go new file mode 100644 index 0000000..51b9ef8 --- /dev/null +++ b/escape_test.go @@ -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("" + Escape(text) + "") + }) + + ctx := map[string]string{ + "url": "http://www.aymerick.com/", + "text": "This is a cool website", + } + + result := tpl.MustExec(ctx) + fmt.Print(result) + // Output: This is a <em>cool</em> website +} diff --git a/eval.go b/eval.go new file mode 100644 index 0000000..1d5f3ef --- /dev/null +++ b/eval.go @@ -0,0 +1,1009 @@ +package handlebars + +import ( + "bytes" + "fmt" + "reflect" + "strconv" + "strings" + + "git.reinaldyrafli.com/aldy505/handlebars-go/ast" +) + +var ( + // @note borrowed from https://github.com/golang/go/tree/master/src/text/template/exec.go + errorType = reflect.TypeOf((*error)(nil)).Elem() + fmtStringerType = reflect.TypeOf((*fmt.Stringer)(nil)).Elem() + + zero reflect.Value +) + +// evalVisitor evaluates a handlebars template with context +type evalVisitor struct { + tpl *Template + + // contexts stack + ctx []reflect.Value + + // current data frame (chained with parent) + dataFrame *DataFrame + + // block parameters stack + blockParams []map[string]interface{} + + // block statements stack + blocks []*ast.BlockStatement + + // expressions stack + exprs []*ast.Expression + + // memoize expressions that were function calls + exprFunc map[*ast.Expression]bool + + // used for info on panic + curNode ast.Node +} + +// NewEvalVisitor instanciate a new evaluation visitor with given context and initial private data frame +// +// If privData is nil, then a default data frame is created +func newEvalVisitor(tpl *Template, ctx interface{}, privData *DataFrame) *evalVisitor { + frame := privData + if frame == nil { + frame = NewDataFrame() + } + + return &evalVisitor{ + tpl: tpl, + ctx: []reflect.Value{reflect.ValueOf(ctx)}, + dataFrame: frame, + exprFunc: make(map[*ast.Expression]bool), + } +} + +// at sets current node +func (v *evalVisitor) at(node ast.Node) { + v.curNode = node +} + +// +// Contexts stack +// + +// pushCtx pushes new context to the stack +func (v *evalVisitor) pushCtx(ctx reflect.Value) { + v.ctx = append(v.ctx, ctx) +} + +// popCtx pops last context from stack +func (v *evalVisitor) popCtx() reflect.Value { + if len(v.ctx) == 0 { + return zero + } + + var result reflect.Value + result, v.ctx = v.ctx[len(v.ctx)-1], v.ctx[:len(v.ctx)-1] + + return result +} + +// rootCtx returns root context +func (v *evalVisitor) rootCtx() reflect.Value { + return v.ctx[0] +} + +// curCtx returns current context +func (v *evalVisitor) curCtx() reflect.Value { + return v.ancestorCtx(0) +} + +// ancestorCtx returns ancestor context +func (v *evalVisitor) ancestorCtx(depth int) reflect.Value { + index := len(v.ctx) - 1 - depth + if index < 0 { + return zero + } + + return v.ctx[index] +} + +// +// Private data frame +// + +// setDataFrame sets new data frame +func (v *evalVisitor) setDataFrame(frame *DataFrame) { + v.dataFrame = frame +} + +// popDataFrame sets back parent data frame +func (v *evalVisitor) popDataFrame() { + v.dataFrame = v.dataFrame.parent +} + +// +// Block Parameters stack +// + +// pushBlockParams pushes new block params to the stack +func (v *evalVisitor) pushBlockParams(params map[string]interface{}) { + v.blockParams = append(v.blockParams, params) +} + +// popBlockParams pops last block params from stack +func (v *evalVisitor) popBlockParams() map[string]interface{} { + var result map[string]interface{} + + if len(v.blockParams) == 0 { + return result + } + + result, v.blockParams = v.blockParams[len(v.blockParams)-1], v.blockParams[:len(v.blockParams)-1] + return result +} + +// blockParam iterates on stack to find given block parameter, and returns its value or nil if not founc +func (v *evalVisitor) blockParam(name string) interface{} { + for i := len(v.blockParams) - 1; i >= 0; i-- { + for k, v := range v.blockParams[i] { + if name == k { + return v + } + } + } + + return nil +} + +// +// Blocks stack +// + +// pushBlock pushes new block statement to stack +func (v *evalVisitor) pushBlock(block *ast.BlockStatement) { + v.blocks = append(v.blocks, block) +} + +// popBlock pops last block statement from stack +func (v *evalVisitor) popBlock() *ast.BlockStatement { + if len(v.blocks) == 0 { + return nil + } + + var result *ast.BlockStatement + result, v.blocks = v.blocks[len(v.blocks)-1], v.blocks[:len(v.blocks)-1] + + return result +} + +// curBlock returns current block statement +func (v *evalVisitor) curBlock() *ast.BlockStatement { + if len(v.blocks) == 0 { + return nil + } + + return v.blocks[len(v.blocks)-1] +} + +// +// Expressions stack +// + +// pushExpr pushes new expression to stack +func (v *evalVisitor) pushExpr(expression *ast.Expression) { + v.exprs = append(v.exprs, expression) +} + +// popExpr pops last expression from stack +func (v *evalVisitor) popExpr() *ast.Expression { + if len(v.exprs) == 0 { + return nil + } + + var result *ast.Expression + result, v.exprs = v.exprs[len(v.exprs)-1], v.exprs[:len(v.exprs)-1] + + return result +} + +// curExpr returns current expression +func (v *evalVisitor) curExpr() *ast.Expression { + if len(v.exprs) == 0 { + return nil + } + + return v.exprs[len(v.exprs)-1] +} + +// +// Error functions +// + +// errPanic panics +func (v *evalVisitor) errPanic(err error) { + panic(fmt.Errorf("Evaluation error: %s\nCurrent node:\n\t%s", err, v.curNode)) +} + +// errorf panics with a custom message +func (v *evalVisitor) errorf(format string, args ...interface{}) { + v.errPanic(fmt.Errorf(format, args...)) +} + +// +// Evaluation +// + +// evalProgram eEvaluates program with given context and returns string result +func (v *evalVisitor) evalProgram(program *ast.Program, ctx interface{}, data *DataFrame, key interface{}) string { + blockParams := make(map[string]interface{}) + + // compute block params + if len(program.BlockParams) > 0 { + blockParams[program.BlockParams[0]] = ctx + } + + if (len(program.BlockParams) > 1) && (key != nil) { + blockParams[program.BlockParams[1]] = key + } + + // push contexts + if len(blockParams) > 0 { + v.pushBlockParams(blockParams) + } + + ctxVal := reflect.ValueOf(ctx) + if ctxVal.IsValid() { + v.pushCtx(ctxVal) + } + + if data != nil { + v.setDataFrame(data) + } + + // evaluate program + result, _ := program.Accept(v).(string) + + // pop contexts + if data != nil { + v.popDataFrame() + } + + if ctxVal.IsValid() { + v.popCtx() + } + + if len(blockParams) > 0 { + v.popBlockParams() + } + + return result +} + +// evalPath evaluates all path parts with given context +func (v *evalVisitor) evalPath(ctx reflect.Value, parts []string, exprRoot bool) (reflect.Value, bool) { + partResolved := false + + for i := 0; i < len(parts); i++ { + part := parts[i] + + // "[foo bar]"" => "foo bar" + if (len(part) >= 2) && (part[0] == '[') && (part[len(part)-1] == ']') { + part = part[1 : len(part)-1] + } + + ctx = v.evalField(ctx, part, exprRoot) + if !ctx.IsValid() { + break + } + + // we resolved at least one part of path + partResolved = true + } + + return ctx, partResolved +} + +// evalField evaluates field with given context +func (v *evalVisitor) evalField(ctx reflect.Value, fieldName string, exprRoot bool) reflect.Value { + result := zero + + ctx, _ = indirect(ctx) + if !ctx.IsValid() { + return result + } + + // check if this is a method call + result, isMeth := v.evalMethod(ctx, fieldName, exprRoot) + if !isMeth { + switch ctx.Kind() { + case reflect.Struct: + // example: firstName => FirstName + expFieldName := strings.Title(fieldName) + + // check if struct have this field and that it is exported + if tField, ok := ctx.Type().FieldByName(expFieldName); ok && (tField.PkgPath == "") { + // struct field + result = ctx.FieldByIndex(tField.Index) + break + } + + // attempts to find template variable name as a struct tag + result = v.evalStructTag(ctx, fieldName) + case reflect.Map: + nameVal := reflect.ValueOf(fieldName) + if nameVal.Type().AssignableTo(ctx.Type().Key()) { + // map key + result = ctx.MapIndex(nameVal) + } + case reflect.Array, reflect.Slice: + if i, err := strconv.Atoi(fieldName); (err == nil) && (i < ctx.Len()) { + result = ctx.Index(i) + } + } + } + + // check if result is a function + result, _ = indirect(result) + if result.Kind() == reflect.Func { + result = v.evalFieldFunc(fieldName, result, exprRoot) + } + + return result +} + +// evalFieldFunc tries to evaluate given method name, and a boolean to indicate if this was a method call +func (v *evalVisitor) evalMethod(ctx reflect.Value, name string, exprRoot bool) (reflect.Value, bool) { + if ctx.Kind() != reflect.Interface && ctx.CanAddr() { + ctx = ctx.Addr() + } + + method := ctx.MethodByName(name) + if !method.IsValid() { + // example: subject() => Subject() + method = ctx.MethodByName(strings.Title(name)) + } + + if !method.IsValid() { + // No valid method found attempt to find a virtual method + if vMethod := getVirtualMethod(name); vMethod != nil { + return vMethod(ctx) + } + return zero, false + } + + return v.evalFieldFunc(name, method, exprRoot), true +} + +// evalFieldFunc evaluates given function +func (v *evalVisitor) evalFieldFunc(name string, funcVal reflect.Value, exprRoot bool) reflect.Value { + ensureValidHelper(name, funcVal) + + var options *Options + if exprRoot { + // create function arg with all params/hash + expr := v.curExpr() + options = v.helperOptions(expr) + + // ok, that expression was a function call + v.exprFunc[expr] = true + } else { + // we are not at root of expression, so we are a parameter... and we don't like + // infinite loops caused by trying to parse ourself forever + options = newEmptyOptions(v) + } + + return v.callFunc(name, funcVal, options) +} + +// evalStructTag checks for the existence of a struct tag containing the +// name of the variable in the template. This allows for a template variable to +// be separated from the field in the struct. +func (v *evalVisitor) evalStructTag(ctx reflect.Value, name string) reflect.Value { + val := reflect.ValueOf(ctx.Interface()) + + for i := 0; i < val.NumField(); i++ { + field := val.Type().Field(i) + tag := field.Tag.Get("handlebars") + if tag == name { + return val.Field(i) + } + } + + return zero +} + +// findBlockParam returns node's block parameter +func (v *evalVisitor) findBlockParam(node *ast.PathExpression) (string, interface{}) { + if len(node.Parts) > 0 { + name := node.Parts[0] + if value := v.blockParam(name); value != nil { + return name, value + } + } + + return "", nil +} + +// evalPathExpression evaluates a path expression +func (v *evalVisitor) evalPathExpression(node *ast.PathExpression, exprRoot bool) interface{} { + var result interface{} + + if name, value := v.findBlockParam(node); value != nil { + // block parameter value + + // We push a new context so we can evaluate the path expression (note: this may be a bad idea). + // + // Example: + // {{#foo as |bar|}} + // {{bar.baz}} + // {{/foo}} + // + // With data: + // {"foo": {"baz": "bat"}} + newCtx := map[string]interface{}{name: value} + + v.pushCtx(reflect.ValueOf(newCtx)) + result = v.evalCtxPathExpression(node, exprRoot) + v.popCtx() + } else { + ctxTried := false + + if node.IsDataRoot() { + // context path + result = v.evalCtxPathExpression(node, exprRoot) + + ctxTried = true + } + + if (result == nil) && node.Data { + // if it is @root, then we tried to evaluate with root context but nothing was found + // so let's try with private data + + // private data + result = v.evalDataPathExpression(node, exprRoot) + } + + if (result == nil) && !ctxTried { + // context path + result = v.evalCtxPathExpression(node, exprRoot) + } + } + + return result +} + +// evalDataPathExpression evaluates a private data path expression +func (v *evalVisitor) evalDataPathExpression(node *ast.PathExpression, exprRoot bool) interface{} { + // find data frame + frame := v.dataFrame + for i := node.Depth; i > 0; i-- { + if frame.parent == nil { + return nil + } + frame = frame.parent + } + + // resolve data + // @note Can be changed to v.evalCtx() as context can't be an array + result, _ := v.evalCtxPath(reflect.ValueOf(frame.data), node.Parts, exprRoot) + return result +} + +// evalCtxPathExpression evaluates a context path expression +func (v *evalVisitor) evalCtxPathExpression(node *ast.PathExpression, exprRoot bool) interface{} { + v.at(node) + + if node.IsDataRoot() { + // `@root` - remove the first part + parts := node.Parts[1:len(node.Parts)] + + result, _ := v.evalCtxPath(v.rootCtx(), parts, exprRoot) + return result + } + + return v.evalDepthPath(node.Depth, node.Parts, exprRoot) +} + +// evalDepthPath iterates on contexts, starting at given depth, until there is one that resolve given path parts +func (v *evalVisitor) evalDepthPath(depth int, parts []string, exprRoot bool) interface{} { + var result interface{} + partResolved := false + + ctx := v.ancestorCtx(depth) + + for (result == nil) && ctx.IsValid() && (depth <= len(v.ctx) && !partResolved) { + // try with context + result, partResolved = v.evalCtxPath(ctx, parts, exprRoot) + + // As soon as we find the first part of a path, we must not try to resolve with parent context if result is finally `nil` + // Reference: "Dotted Names - Context Precedence" mustache test + if !partResolved && (result == nil) { + // try with previous context + depth++ + ctx = v.ancestorCtx(depth) + } + } + + return result +} + +// evalCtxPath evaluates path with given context +func (v *evalVisitor) evalCtxPath(ctx reflect.Value, parts []string, exprRoot bool) (interface{}, bool) { + var result interface{} + partResolved := false + + switch ctx.Kind() { + case reflect.Array, reflect.Slice: + // Array context + var results []interface{} + + for i := 0; i < ctx.Len(); i++ { + value, _ := v.evalPath(ctx.Index(i), parts, exprRoot) + if value.IsValid() { + results = append(results, value.Interface()) + } + } + + result = results + default: + // NOT array context + var value reflect.Value + + value, partResolved = v.evalPath(ctx, parts, exprRoot) + if value.IsValid() { + result = value.Interface() + } + } + + return result, partResolved +} + +// +// Helpers +// + +// isHelperCall returns true if given expression is a helper call +func (v *evalVisitor) isHelperCall(node *ast.Expression) bool { + if helperName := node.HelperName(); helperName != "" { + return v.findHelper(helperName) != zero + } + return false +} + +// findHelper finds given helper +func (v *evalVisitor) findHelper(name string) reflect.Value { + // check template helpers + if h := v.tpl.findHelper(name); h != zero { + return h + } + + // check global helpers + return findHelper(name) +} + +// callFunc calls function with given options +func (v *evalVisitor) callFunc(name string, funcVal reflect.Value, options *Options) reflect.Value { + params := options.Params() + + funcType := funcVal.Type() + + // @todo Is there a better way to do that ? + strType := reflect.TypeOf("") + boolType := reflect.TypeOf(true) + + // check parameters number + addOptions := false + numIn := funcType.NumIn() + + if numIn == len(params)+1 { + lastArgType := funcType.In(numIn - 1) + if reflect.TypeOf(options).AssignableTo(lastArgType) { + addOptions = true + } + } + + if !addOptions && (len(params) != numIn) { + v.errorf("Helper '%s' called with wrong number of arguments, needed %d but got %d", name, numIn, len(params)) + } + + // check and collect arguments + args := make([]reflect.Value, numIn) + for i, param := range params { + arg := reflect.ValueOf(param) + argType := funcType.In(i) + + if !arg.IsValid() { + if canBeNil(argType) { + arg = reflect.Zero(argType) + } else if argType.Kind() == reflect.String { + arg = reflect.ValueOf("") + } else { + // @todo Maybe we can panic on that + return reflect.Zero(strType) + } + } + + if !arg.Type().AssignableTo(argType) { + if strType.AssignableTo(argType) { + // convert parameter to string + arg = reflect.ValueOf(strValue(arg)) + } else if boolType.AssignableTo(argType) { + // convert parameter to bool + val, _ := isTrueValue(arg) + arg = reflect.ValueOf(val) + } else { + v.errorf("Helper %s called with argument %d with type %s but it should be %s", name, i, arg.Type(), argType) + } + } + + args[i] = arg + } + + if addOptions { + args[numIn-1] = reflect.ValueOf(options) + } + + result := funcVal.Call(args) + + return result[0] +} + +// callHelper invoqs helper function for given expression node +func (v *evalVisitor) callHelper(name string, helper reflect.Value, node *ast.Expression) interface{} { + result := v.callFunc(name, helper, v.helperOptions(node)) + if !result.IsValid() { + return nil + } + + // @todo We maybe want to ensure here that helper returned a string or a SafeString + return result.Interface() +} + +// helperOptions computes helper options argument from an expression +func (v *evalVisitor) helperOptions(node *ast.Expression) *Options { + var params []interface{} + var hash map[string]interface{} + + for _, paramNode := range node.Params { + param := paramNode.Accept(v) + params = append(params, param) + } + + if node.Hash != nil { + hash, _ = node.Hash.Accept(v).(map[string]interface{}) + } + + return newOptions(v, params, hash) +} + +// +// Partials +// + +// findPartial finds given partial +func (v *evalVisitor) findPartial(name string) *partial { + // check template partials + if p := v.tpl.findPartial(name); p != nil { + return p + } + + // check global partials + return findPartial(name) +} + +// partialContext computes partial context +func (v *evalVisitor) partialContext(node *ast.PartialStatement) reflect.Value { + if nb := len(node.Params); nb > 1 { + v.errorf("Unsupported number of partial arguments: %d", nb) + } + + if (len(node.Params) > 0) && (node.Hash != nil) { + v.errorf("Passing both context and named parameters to a partial is not allowed") + } + + if len(node.Params) == 1 { + return reflect.ValueOf(node.Params[0].Accept(v)) + } + + if node.Hash != nil { + hash, _ := node.Hash.Accept(v).(map[string]interface{}) + return reflect.ValueOf(hash) + } + + return zero +} + +// evalPartial evaluates a partial +func (v *evalVisitor) evalPartial(p *partial, node *ast.PartialStatement) string { + // get partial template + partialTpl, err := p.template() + if err != nil { + v.errPanic(err) + } + + // push partial context + ctx := v.partialContext(node) + if ctx.IsValid() { + v.pushCtx(ctx) + } + + // evaluate partial template + result, _ := partialTpl.program.Accept(v).(string) + + // ident partial + result = indentLines(result, node.Indent) + + if ctx.IsValid() { + v.popCtx() + } + + return result +} + +// indentLines indents all lines of given string +func indentLines(str string, indent string) string { + if indent == "" { + return str + } + + var indented []string + + lines := strings.Split(str, "\n") + for i, line := range lines { + if (i == (len(lines) - 1)) && (line == "") { + // input string ends with a new line + indented = append(indented, line) + } else { + indented = append(indented, indent+line) + } + } + + return strings.Join(indented, "\n") +} + +// +// Functions +// + +// wasFuncCall returns true if given expression was a function call +func (v *evalVisitor) wasFuncCall(node *ast.Expression) bool { + // check if expression was tagged as a function call + return v.exprFunc[node] +} + +// +// Visitor interface +// + +// Statements + +// VisitProgram implements corresponding Visitor interface method +func (v *evalVisitor) VisitProgram(node *ast.Program) interface{} { + v.at(node) + + buf := new(bytes.Buffer) + + for _, n := range node.Body { + if str := Str(n.Accept(v)); str != "" { + if _, err := buf.Write([]byte(str)); err != nil { + v.errPanic(err) + } + } + } + + return buf.String() +} + +// VisitMustache implements corresponding Visitor interface method +func (v *evalVisitor) VisitMustache(node *ast.MustacheStatement) interface{} { + v.at(node) + + // evaluate expression + expr := node.Expression.Accept(v) + + // check if this is a safe string + isSafe := isSafeString(expr) + + // get string value + str := Str(expr) + if !isSafe && !node.Unescaped { + // escape html + str = Escape(str) + } + + return str +} + +// VisitBlock implements corresponding Visitor interface method +func (v *evalVisitor) VisitBlock(node *ast.BlockStatement) interface{} { + v.at(node) + + v.pushBlock(node) + + var result interface{} + + // evaluate expression + expr := node.Expression.Accept(v) + + if v.isHelperCall(node.Expression) || v.wasFuncCall(node.Expression) { + // it is the responsibility of the helper/function to evaluate block + result = expr + } else { + val := reflect.ValueOf(expr) + + truth, _ := isTrueValue(val) + if truth { + if node.Program != nil { + switch val.Kind() { + case reflect.Array, reflect.Slice: + concat := "" + + // Array context + for i := 0; i < val.Len(); i++ { + // Computes new private data frame + frame := v.dataFrame.newIterDataFrame(val.Len(), i, nil) + + // Evaluate program + concat += v.evalProgram(node.Program, val.Index(i).Interface(), frame, i) + } + + result = concat + default: + // NOT array + result = v.evalProgram(node.Program, expr, nil, nil) + } + } + } else if node.Inverse != nil { + result, _ = node.Inverse.Accept(v).(string) + } + } + + v.popBlock() + + return result +} + +// VisitPartial implements corresponding Visitor interface method +func (v *evalVisitor) VisitPartial(node *ast.PartialStatement) interface{} { + v.at(node) + + // partialName: helperName | sexpr + name, ok := ast.HelperNameStr(node.Name) + if !ok { + if subExpr, ok := node.Name.(*ast.SubExpression); ok { + name, _ = subExpr.Accept(v).(string) + } + } + + if name == "" { + v.errorf("Unexpected partial name: %q", node.Name) + } + + partial := v.findPartial(name) + if partial == nil { + v.errorf("Partial not found: %s", name) + } + + return v.evalPartial(partial, node) +} + +// VisitContent implements corresponding Visitor interface method +func (v *evalVisitor) VisitContent(node *ast.ContentStatement) interface{} { + v.at(node) + + // write content as is + return node.Value +} + +// VisitComment implements corresponding Visitor interface method +func (v *evalVisitor) VisitComment(node *ast.CommentStatement) interface{} { + v.at(node) + + // ignore comments + return "" +} + +// Expressions + +// VisitExpression implements corresponding Visitor interface method +func (v *evalVisitor) VisitExpression(node *ast.Expression) interface{} { + v.at(node) + + var result interface{} + done := false + + v.pushExpr(node) + + // helper call + if helperName := node.HelperName(); helperName != "" { + if helper := v.findHelper(helperName); helper != zero { + result = v.callHelper(helperName, helper, node) + done = true + } + } + + if !done { + // literal + if literal, ok := node.LiteralStr(); ok { + if val := v.evalField(v.curCtx(), literal, true); val.IsValid() { + result = val.Interface() + done = true + } + } + } + + if !done { + // field path + if path := node.FieldPath(); path != nil { + // @todo Find a cleaner way ! Don't break the pattern ! + // this is an exception to visitor pattern, because we need to pass the info + // that this path is at root of current expression + if val := v.evalPathExpression(path, true); val != nil { + result = val + } + } + } + + v.popExpr() + + return result +} + +// VisitSubExpression implements corresponding Visitor interface method +func (v *evalVisitor) VisitSubExpression(node *ast.SubExpression) interface{} { + v.at(node) + + return node.Expression.Accept(v) +} + +// VisitPath implements corresponding Visitor interface method +func (v *evalVisitor) VisitPath(node *ast.PathExpression) interface{} { + return v.evalPathExpression(node, false) +} + +// Literals + +// VisitString implements corresponding Visitor interface method +func (v *evalVisitor) VisitString(node *ast.StringLiteral) interface{} { + v.at(node) + + return node.Value +} + +// VisitBoolean implements corresponding Visitor interface method +func (v *evalVisitor) VisitBoolean(node *ast.BooleanLiteral) interface{} { + v.at(node) + + return node.Value +} + +// VisitNumber implements corresponding Visitor interface method +func (v *evalVisitor) VisitNumber(node *ast.NumberLiteral) interface{} { + v.at(node) + + return node.Number() +} + +// Miscellaneous + +// VisitHash implements corresponding Visitor interface method +func (v *evalVisitor) VisitHash(node *ast.Hash) interface{} { + v.at(node) + + result := make(map[string]interface{}) + + for _, pair := range node.Pairs { + if value := pair.Accept(v); value != nil { + result[pair.Key] = value + } + } + + return result +} + +// VisitHashPair implements corresponding Visitor interface method +func (v *evalVisitor) VisitHashPair(node *ast.HashPair) interface{} { + v.at(node) + + return node.Val.Accept(v) +} diff --git a/eval_test.go b/eval_test.go new file mode 100644 index 0000000..13481c8 --- /dev/null +++ b/eval_test.go @@ -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(`
` + options.Fn() + "
") + }}, + nil, + `My new blog post -
I have so many things to say!
`, + }, + { + "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 := `
+

By {{author.FirstName}} {{Author.lastName}}

+
{{Body}}
+ +

Comments

+ + {{#each comments}} +

By {{Author.FirstName}} {{author.LastName}}

+
{{body}}
+ {{/each}} +
` + + expected := `
+

By Jean Valjean

+
Life is difficult
+ +

Comments

+ +

By Marcel Beliveau

+
LOL!
+
` + + 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 := `
+

{{real-name}}

+
    +
  • City: {{info.location}}
  • +
  • Rug: {{info.[r.u.g]}}
  • +
  • Activity: {{info.activity}}
  • +
+ {{#each other-names}} +

{{alias-name}}

+ {{/each}} +
` + + expected := `
+

Lebowski

+
    +
  • City: Venice
  • +
  • Rug: Tied The Room Together
  • +
  • Activity: Bowling
  • +
+

his dudeness

+

el duderino

+
` + + 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) + } +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..e88fe25 --- /dev/null +++ b/example_test.go @@ -0,0 +1,119 @@ +package handlebars_test + +import ( + "fmt" + + "git.reinaldyrafli.com/aldy505/handlebars-go" +) + +func Example() { + source := "

{{title}}

{{body.content}}

" + + 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:

foo

bar

+} + +func Example_struct() { + source := `
+

By {{fullName author}}

+
{{body}}
+ +

Comments

+ + {{#each comments}} +

By {{fullName author}}

+
{{content}}
+ {{/each}} +
` + + 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:
+ //

By Jean Valjean

+ //
Life is difficult
+ // + //

Comments

+ // + //

By Marcel Beliveau

+ //
LOL!
+ //
+} + +func ExampleRender() { + tpl := "

{{title}}

{{body.content}}

" + + 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:

foo

bar

+} + +func ExampleMustRender() { + tpl := "

{{title}}

{{body.content}}

" + + 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:

foo

bar

+} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..70a9e07 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.reinaldyrafli.com/aldy505/handlebars-go + +go 1.20 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/handlebars-gopher.png b/handlebars-gopher.png new file mode 100644 index 0000000..fbae93b Binary files /dev/null and b/handlebars-gopher.png differ diff --git a/handlebars.go b/handlebars.go new file mode 100644 index 0000000..17a8082 --- /dev/null +++ b/handlebars.go @@ -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) +} diff --git a/helper.go b/helper.go new file mode 100644 index 0000000..f57e722 --- /dev/null +++ b/helper.go @@ -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() +} diff --git a/helper_test.go b/helper_test.go new file mode 100644 index 0000000..5a45371 --- /dev/null +++ b/helper_test.go @@ -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", + ``, + map[string]interface{}{"value": "test"}, + nil, nil, nil, + ``, + }, + { + "#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) + } +} diff --git a/internal/handlebarsjs/base_test.go b/internal/handlebarsjs/base_test.go new file mode 100644 index 0000000..ce458e9 --- /dev/null +++ b/internal/handlebarsjs/base_test.go @@ -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()) + } + } + } + } + } +} diff --git a/internal/handlebarsjs/basic_test.go b/internal/handlebarsjs/basic_test.go new file mode 100644 index 0000000..e82666d --- /dev/null +++ b/internal/handlebarsjs/basic_test.go @@ -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, + "&"'`\\<>", + }, + { + "escaping expressions (9)", + "{{awesome}}", + map[string]string{"awesome": "Escaped, looks like: <b>"}, + nil, nil, nil, + "Escaped, <b> looks like: &lt;b&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) + } + } +} diff --git a/internal/handlebarsjs/blocks_test.go b/internal/handlebarsjs/blocks_test.go new file mode 100644 index 0000000..7516985 --- /dev/null +++ b/internal/handlebarsjs/blocks_test.go @@ -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) +} diff --git a/internal/handlebarsjs/builtins_test.go b/internal/handlebarsjs/builtins_test.go new file mode 100644 index 0000000..a85ca6a --- /dev/null +++ b/internal/handlebarsjs/builtins_test.go @@ -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{"#1": {"text": "goodbye"}, 2: {"text": "GOODBYE"}}, "world": "world"}, + nil, nil, nil, + []string{"<b>#1</b>. goodbye! 2. GOODBYE! cruel world!", "2. GOODBYE! <b>#1</b>. 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) +} diff --git a/internal/handlebarsjs/data_test.go b/internal/handlebarsjs/data_test.go new file mode 100644 index 0000000..c265ad0 --- /dev/null +++ b/internal/handlebarsjs/data_test.go @@ -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) +} diff --git a/internal/handlebarsjs/doc.go b/internal/handlebarsjs/doc.go new file mode 100644 index 0000000..7ae85d7 --- /dev/null +++ b/internal/handlebarsjs/doc.go @@ -0,0 +1,2 @@ +// Package handlebarjs contains all the tests that come from handlebars.js project. +package handlebarsjs diff --git a/internal/handlebarsjs/helpers_test.go b/internal/handlebarsjs/helpers_test.go new file mode 100644 index 0000000..40aa326 --- /dev/null +++ b/internal/handlebarsjs/helpers_test.go @@ -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(`%s`, 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 "
" + options.Fn() + "
" +} + +func formCtxHelper(context interface{}, options *handlebars.Options) string { + return "
" + options.FnWith(context) + "
" +} + +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 := "
    " + for i := 0; i < val.Len(); i++ { + result += "
  • " + result += options.FnWith(val.Index(i).Interface()) + result += "
  • " + } + result += "
" + + return result + } + } + + return "

" + options.Inverse() + "

" +} + +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, + `Goodbye`, + }, + { + "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, + `Goodbye`, + }, + { + // 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}}

{{name}}

{{/form}}", + map[string]interface{}{"name": "Yehuda"}, + nil, + map[string]interface{}{"form": formHelper}, + nil, + "

Yehuda

", + }, + { + "block helper should have context in this", + "
    {{#people}}
  • {{#link}}{{name}}{{/link}}
  • {{/people}}
", + 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("%s", options.ValueStr("id"), options.Fn()) + }}, + nil, + ``, + }, + { + "block helper for undefined value", + "{{#empty}}shouldn't render{{/empty}}", + nil, nil, nil, nil, + "", + }, + { + "block helper passing a new context", + "{{#form yehuda}}

{{name}}

{{/form}}", + map[string]map[string]string{"yehuda": {"name": "Yehuda"}}, + nil, + map[string]interface{}{"form": formCtxHelper}, + nil, + "

Yehuda

", + }, + { + "block helper passing a complex path context", + "{{#form yehuda/cat}}

{{name}}

{{/form}}", + map[string]map[string]interface{}{"yehuda": {"name": "Yehuda", "cat": map[string]string{"name": "Harold"}}}, + nil, + map[string]interface{}{"form": formCtxHelper}, + nil, + "

Harold

", + }, + { + "nested block helpers", + "{{#form yehuda}}

{{name}}

{{#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("%s", options.ValueStr("name"), options.Fn()) + }, "form": formCtxHelper}, + nil, + `

Yehuda

Hello
`, + }, + { + "block helper inverted sections (1) - an inverse wrapper is passed in as a new context", + "{{#list people}}{{name}}{{^}}Nobody's here{{/list}}", + map[string][]map[string]string{"people": {{"name": "Alan"}, {"name": "Yehuda"}}}, + nil, + map[string]interface{}{"list": listHelper}, + nil, + `
  • Alan
  • Yehuda
`, + }, + { + "block helper inverted sections (2) - an inverse wrapper can be optionally called", + "{{#list people}}{{name}}{{^}}Nobody's here{{/list}}", + map[string][]map[string]string{"people": {}}, + nil, + map[string]interface{}{"list": listHelper}, + nil, + `

Nobody's here

`, + }, + { + "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, + `

Nobody's here

`, + }, + + { + "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) +} diff --git a/internal/handlebarsjs/partials_test.go b/internal/handlebarsjs/partials_test.go new file mode 100644 index 0000000..1c5f8b9 --- /dev/null +++ b/internal/handlebarsjs/partials_test.go @@ -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": `{{url}}`}, + `Dudes: Yehuda http://yehuda Alan http://alan `, + }, + + // @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) +} diff --git a/internal/handlebarsjs/subexpressions_test.go b/internal/handlebarsjs/subexpressions_test.go new file mode 100644 index 0000000..ebdfc26 --- /dev/null +++ b/internal/handlebarsjs/subexpressions_test.go @@ -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(``) + }, + "t": func(param string) handlebars.SafeString { + return handlebars.SafeString(param) + }, + }, + nil, + ``, + }, + { + "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(``) + }, + "t": func(param string) handlebars.SafeString { + return handlebars.SafeString(param) + }, + }, + nil, + ``, + }, + + // @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) +} diff --git a/internal/handlebarsjs/whitespace_test.go b/internal/handlebarsjs/whitespace_test.go new file mode 100644 index 0000000..5e1ef9f --- /dev/null +++ b/internal/handlebarsjs/whitespace_test.go @@ -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<", + }, + { + "should strip whitespace around mustache calls (2)", + " {{~foo}} ", + map[string]string{"foo": "bar<"}, + nil, nil, nil, + "bar< ", + }, + { + "should strip whitespace around mustache calls (3)", + " {{foo~}} ", + map[string]string{"foo": "bar<"}, + nil, nil, nil, + " bar<", + }, + { + "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) +} diff --git a/lexer/lexer.go b/lexer/lexer.go new file mode 100644 index 0000000..48899f8 --- /dev/null +++ b/lexer/lexer.go @@ -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) +} diff --git a/lexer/lexer_test.go b/lexer/lexer_test.go new file mode 100644 index 0000000..71e8bf0 --- /dev/null +++ b/lexer/lexer_test.go @@ -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 +} diff --git a/lexer/token.go b/lexer/token.go new file mode 100644 index 0000000..13cf2e6 --- /dev/null +++ b/lexer/token.go @@ -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 +} diff --git a/mustache/specs/comments.json b/mustache/specs/comments.json new file mode 100644 index 0000000..924ed46 --- /dev/null +++ b/mustache/specs/comments.json @@ -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: ><" + } + ] +} diff --git a/mustache/specs/comments.yml b/mustache/specs/comments.yml new file mode 100644 index 0000000..3bad09f --- /dev/null +++ b/mustache/specs/comments.yml @@ -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: ><' diff --git a/mustache/specs/delimiters.json b/mustache/specs/delimiters.json new file mode 100644 index 0000000..485e84b --- /dev/null +++ b/mustache/specs/delimiters.json @@ -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": "||" + } + ] +} diff --git a/mustache/specs/delimiters.yml b/mustache/specs/delimiters.yml new file mode 100644 index 0000000..ce80b17 --- /dev/null +++ b/mustache/specs/delimiters.yml @@ -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: '||' diff --git a/mustache/specs/interpolation.json b/mustache/specs/interpolation.json new file mode 100644 index 0000000..d758657 --- /dev/null +++ b/mustache/specs/interpolation.json @@ -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: & " < >\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: & " < >\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": "|---|" + } + ] +} diff --git a/mustache/specs/interpolation.yml b/mustache/specs/interpolation.yml new file mode 100644 index 0000000..aae2cf4 --- /dev/null +++ b/mustache/specs/interpolation.yml @@ -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: & " < > + + - 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: & " < > + + - 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: '|---|' diff --git a/mustache/specs/inverted.json b/mustache/specs/inverted.json new file mode 100644 index 0000000..5e881d1 --- /dev/null +++ b/mustache/specs/inverted.json @@ -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": "|=|" + } + ] +} diff --git a/mustache/specs/inverted.yml b/mustache/specs/inverted.yml new file mode 100644 index 0000000..148b3f5 --- /dev/null +++ b/mustache/specs/inverted.yml @@ -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: '|=|' diff --git a/mustache/specs/partials.json b/mustache/specs/partials.json new file mode 100644 index 0000000..89dde46 --- /dev/null +++ b/mustache/specs/partials.json @@ -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>" + }, + { + "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": "|[]|" + } + ] +} diff --git a/mustache/specs/partials.yml b/mustache/specs/partials.yml new file mode 100644 index 0000000..8c41543 --- /dev/null +++ b/mustache/specs/partials.yml @@ -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>' + + # 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: '|[]|' diff --git a/mustache/specs/sections.json b/mustache/specs/sections.json new file mode 100644 index 0000000..3acc414 --- /dev/null +++ b/mustache/specs/sections.json @@ -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": "|=|" + } + ] +} diff --git a/mustache/specs/sections.yml b/mustache/specs/sections.yml new file mode 100644 index 0000000..fdfd799 --- /dev/null +++ b/mustache/specs/sections.yml @@ -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: '|=|' diff --git a/mustache/specs/~dynamic-names.json b/mustache/specs/~dynamic-names.json new file mode 100644 index 0000000..b1faca6 --- /dev/null +++ b/mustache/specs/~dynamic-names.json @@ -0,0 +1,316 @@ +{ + "__ATTN__": "Do not edit this file; changes belong in the appropriate YAML file.", + "overview": "Rationale: this special notation was introduced primarily to allow the dynamic\nloading of partials. The main advantage that this notation offers is to allow\ndynamic loading of partials, which is particularly useful in cases where\npolymorphic data needs to be rendered in different ways. Such cases would\notherwise be possible to render only with solutions that are convoluted,\ninefficient, or both.\n\nExample.\nLet's consider the following data:\n\n items: [\n { content: 'Hello, World!' },\n { url: 'http://example.com/foo.jpg' },\n { content: 'Some text' },\n { content: 'Some other text' },\n { url: 'http://example.com/bar.jpg' },\n { url: 'http://example.com/baz.jpg' },\n { content: 'Last text here' }\n ]\n\nThe goal is to render the different types of items in different ways. The\nitems having a key named `content` should be rendered with the template\n`text.mustache`:\n\n {{!text.mustache}}\n {{content}}\n\nAnd the items having a key named `url` should be rendered with the template\n`image.mustache`:\n\n {{!image.mustache}}\n \n\nThere are already several ways to achieve this goal, here below are\nillustrated and discussed the most significant solutions to this problem.\n\nUsing Pre-Processing\n\nThe idea is to use a secondary templating mechanism to dynamically generate\nthe template that will be rendered.\nThe template that our secondary templating mechanism generates might look\nlike this:\n\n {{!template.mustache}}\n {{items.1.content}}\n \n {{items.3.content}}\n {{items.4.content}}\n \n \n {{items.7.content}}\n\nThis solutions offers the advantages of having more control over the template\nand minimizing the template blocks to the essential ones.\nThe drawbacks are the rendering speed and the complexity that the secondary\ntemplating mechanism requires.\n\nUsing Lambdas\n\nThe idea is to inject functions into the data that will be later called from\nthe template.\nThis way the data will look like this:\n\n items: [\n {\n content: 'Hello, World!',\n html: function() { return '{{>text}}'; }\n },\n {\n url: 'http://example.com/foo.jpg',\n html: function() { return '{{>image}}'; }\n },\n {\n content: 'Some text',\n html: function() { return '{{>text}}'; }\n },\n {\n content: 'Some other text',\n html: function() { return '{{>text}}'; }\n },\n {\n url: 'http://example.com/bar.jpg',\n html: function() { return '{{>image}}'; }\n },\n {\n url: 'http://example.com/baz.jpg',\n html: function() { return '{{>image}}'; }\n },\n {\n content: 'Last text here',\n html: function() { return '{{>text}}'; }\n }\n ]\n\nAnd the template will look like this:\n\n {{!template.mustache}}\n {{#items}}\n {{{html}}}\n {{/items}}\n\nThe advantage this solution offers is to have a light main template.\nThe drawback is that the data needs to embed logic and template tags in\nit.\n\nUsing If-Else Blocks\n\nThe idea is to put some logic into the main template so it can select the\ntemplates at rendering time:\n\n {{!template.mustache}}\n {{#items}}\n {{#url}}\n {{>image}}\n {{/url}}\n {{#content}}\n {{>text}}\n {{/content}}\n {{/items}}\n\nThe main advantage of this solution is that it works without adding any\noverhead fields to the data. It also documents which external templates are\nappropriate for expansion in this position.\nThe drawback is that this solution isn't optimal for heterogeneous data sets\nas the main template grows linearly with the number of polymorphic variants.\n\nUsing Dynamic Names\n\nThis is the solution proposed by this spec.\nThe idea is to load partials dynamically.\nThis way the data items have to be tagged with the corresponding partial name:\n\n items: [\n { content: 'Hello, World!', dynamic: 'text' },\n { url: 'http://example.com/foo.jpg', dynamic: 'image' },\n { content: 'Some text', dynamic: 'text' },\n { content: 'Some other text', dynamic: 'text' },\n { url: 'http://example.com/bar.jpg', dynamic: 'image' },\n { url: 'http://example.com/baz.jpg', dynamic: 'image' },\n { content: 'Last text here', dynamic: 'text' }\n ]\n\nAnd the template would simple look like this:\n\n {{!template.mustache}}\n {{#items}}\n {{>*dynamic}}\n {{/items}}\n\nSummary:\n\n +----------------+---------------------+-----------------------------------+\n | Approach | Pros | Cons |\n +----------------+---------------------+-----------------------------------+\n | Pre-Processing | Essential template, | Secondary templating system |\n | | more control | needed, slower rendering |\n | Lambdas | Slim template | Data tagging, logic in data |\n | If Blocks | No data overhead, | Template linear growth |\n | | self-documenting | |\n | Dynamic Names | Slim template | Data tagging |\n +----------------+---------------------+-----------------------------------+\n\nDynamic Names are a special notation to dynamically determine a tag's content.\n\nDynamic Names MUST be a non-whitespace character sequence NOT containing\nthe current closing delimiter. A Dynamic Name consists of an asterisk,\nfollowed by a dotted name. The dotted name follows the same notation as in an\nInterpolation tag.\n\nThis tag's dotted name, which is the Dynamic Name excluding the\nleading asterisk, references a key in the context whose value will be used in\nplace of the Dynamic Name itself as content of the tag. The dotted name\nresolution produces the same value as an Interpolation tag and does not affect\nthe context for further processing.\n\nSet Delimiter tags MUST NOT affect the resolution of a Dynamic Name. The\nDynamic Names MUST be resolved against the context stack local to the tag.\nFailed resolution of the dynamic name SHOULD result in nothing being rendered.\n\nEngines that implement Dynamic Names MUST support their use in Partial tags.\nIn engines that also implement the optional inheritance spec, Dynamic Names\ninside Parent tags SHOULD be supported as well. Dynamic Names cannot be\nresolved more than once (Dynamic Names cannot be nested).\n", + "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.\n", + "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>" + }, + { + "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": "\"\"" + }, + { + "name": "Surrounding Whitespace", + "desc": "A dynamic partial should not alter surrounding whitespace; any\nwhitespace preceding the tag should be treated as indentation while any\nwhitespace succeding the tag should be left untouched.\n", + "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\nshould be treated as indentation.\n", + "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": "\\\n {{>*dynamic}}\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": { + "dynamic": "partial", + "boolean": true + }, + "template": "|{{> * dynamic }}|", + "partials": { + "partial": "[]" + }, + "expected": "|[]|" + } + ] +} diff --git a/mustache/specs/~dynamic-names.yml b/mustache/specs/~dynamic-names.yml new file mode 100644 index 0000000..a9f5d03 --- /dev/null +++ b/mustache/specs/~dynamic-names.yml @@ -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}} + + + 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}} + + {{items.3.content}} + {{items.4.content}} + + + {{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>' + + - 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: '|[]|' diff --git a/mustache/specs/~inheritance.json b/mustache/specs/~inheritance.json new file mode 100644 index 0000000..0a6878b --- /dev/null +++ b/mustache/specs/~inheritance.json @@ -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": "" + }, + "template": "{{$foo}}default {{{bar}}} content{{/foo}}\n", + "expected": "default 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": "{{parent}}|{{' } + template: | + {{$foo}}default {{{bar}}} content{{/foo}} + expected: | + default 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: | + {{parent}}|{{ {{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": "<>>" + }, + { + "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": "" + }, + { + "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": "<>" + } + ] +} diff --git a/mustache/specs/~lambdas.yml b/mustache/specs/~lambdas.yml new file mode 100644 index 0000000..2e3316b --- /dev/null +++ b/mustache/specs/~lambdas.yml @@ -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: "<>>" + + - 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: "" + + - 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: "<>" diff --git a/mustache_test.go b/mustache_test.go new file mode 100644 index 0000000..70a850b --- /dev/null +++ b/mustache_test.go @@ -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, + "<>>", + }, + + // // 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, + // "", + // }, + + // // 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) +} diff --git a/parser/parser.go b/parser/parser.go new file mode 100644 index 0000000..0606919 --- /dev/null +++ b/parser/parser.go @@ -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) +} diff --git a/parser/parser_test.go b/parser/parser_test.go new file mode 100644 index 0000000..e36965c --- /dev/null +++ b/parser/parser_test.go @@ -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' ] +} diff --git a/parser/whitespace.go b/parser/whitespace.go new file mode 100644 index 0000000..94b7fb7 --- /dev/null +++ b/parser/whitespace.go @@ -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 } diff --git a/partial.go b/partial.go new file mode 100644 index 0000000..199b056 --- /dev/null +++ b/partial.go @@ -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 +} diff --git a/string.go b/string.go new file mode 100644 index 0000000..7eba5db --- /dev/null +++ b/string.go @@ -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 +} diff --git a/string_test.go b/string_test.go new file mode 100644 index 0000000..c78c437 --- /dev/null +++ b/string_test.go @@ -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("FOO BAR") + }) + + tpl := MustParse("{{em}}") + + result := tpl.MustExec(nil) + fmt.Print(result) + // Output: FOO BAR +} diff --git a/template.go b/template.go new file mode 100644 index 0000000..6865ff0 --- /dev/null +++ b/template.go @@ -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) +} diff --git a/template_test.go b/template_test.go new file mode 100644 index 0000000..57c8fdd --- /dev/null +++ b/template_test.go @@ -0,0 +1,166 @@ +package handlebars + +import ( + "fmt" + "testing" +) + +var sourceBasic = `
+

{{title}}

+
+ {{body}} +
+
` + +var basicAST = `CONTENT[ '
+

' ] +{{ PATH:title [] }} +CONTENT[ '

+
+ ' ] +{{ PATH:body [] }} +CONTENT[ ' +
+
' ] +` + +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 := "

{{title}}

{{body.content}}

" + + 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:

foo

bar

+} + +func ExampleTemplate_MustExec() { + source := "

{{title}}

{{body.content}}

" + + 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:

foo

bar

+} + +func ExampleTemplate_ExecWith() { + source := "

{{title}}

{{#body}}{{content}} and {{@baz.bat}}{{/body}}

" + + 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:

foo

bar and unicorns

+} + +func ExampleTemplate_PrintAST() { + source := "

{{title}}

{{#body}}{{content}} and {{@baz.bat}}{{/body}}

" + + // parse template + tpl := MustParse(source) + + // print AST + output := tpl.PrintAST() + + fmt.Print(output) + // Output: CONTENT[ '

' ] + // {{ PATH:title [] }} + // CONTENT[ '

' ] + // BLOCK: + // PATH:body [] + // PROGRAM: + // {{ PATH:content [] + // }} + // CONTENT[ ' and ' ] + // {{ @PATH:baz/bat [] + // }} + // CONTENT[ '

' ] + // +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..9e5ac14 --- /dev/null +++ b/utils.go @@ -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)] +} diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..7314d8d --- /dev/null +++ b/utils_test.go @@ -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 +} diff --git a/virtualmethods.go b/virtualmethods.go new file mode 100644 index 0000000..90cdf55 --- /dev/null +++ b/virtualmethods.go @@ -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 +}