├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── gleam.toml ├── manifest.toml ├── src ├── handles.gleam └── handles │ ├── ctx.gleam │ ├── error.gleam │ ├── format.gleam │ └── internal │ ├── block.gleam │ ├── ctx_utils.gleam │ ├── engine.gleam │ ├── parser.gleam │ └── tokenizer.gleam └── test ├── api_tests ├── each_test.gleam ├── if_test.gleam ├── partial_test.gleam ├── property_test.gleam └── unless_test.gleam ├── handles_test.gleam ├── unit_tests ├── ctx_test.gleam ├── engine_test.gleam ├── format_test.gleam ├── parser_test.gleam └── tokenizer_test.gleam └── user_stories ├── big_template.gleam ├── hello_test.gleam ├── knattarna_test.gleam └── nested_block_test.gleam /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0.2" 18 | gleam-version: "1.2.1" 19 | rebar3-version: "3" 20 | # elixir-version: "1.15.4" 21 | - run: gleam deps download 22 | - run: gleam test 23 | - run: gleam format --check src test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Oliver Anteros 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Package Version](https://img.shields.io/hexpm/v/handles)](https://hex.pm/packages/handles) 2 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/handles/) 3 | 4 | # handles 5 | 6 | `handles` is a templating language written in pure Gleam. Heavily inspired by [mustache](https://mustache.github.io/) and [Handlebars.js](https://github.com/handlebars-lang/handlebars.js). 7 | 8 | ```sh 9 | gleam add handles 10 | ``` 11 | 12 | ```gleam 13 | import gleam/io 14 | import gleam/string_builder 15 | import handles 16 | import handles/ctx 17 | 18 | pub fn main() { 19 | let assert Ok(greet_template) = handles.prepare("Hello {{.}}!") 20 | let assert Ok(template) = 21 | handles.prepare("{{>greet world}}\n{{>greet community}}\n{{>greet you}}") 22 | let assert Ok(string) = 23 | handles.run( 24 | template, 25 | ctx.Dict([ 26 | ctx.Prop("world", ctx.Str("World")), 27 | ctx.Prop("community", ctx.Str("Gleam Community")), 28 | ctx.Prop("you", ctx.Str("YOU")), 29 | ]), 30 | [#("greet", greet_template)], 31 | ) 32 | 33 | string 34 | |> string_builder.to_string 35 | |> io.println 36 | } 37 | ``` 38 | 39 | Further documentation can be found at . 40 | 41 | ## Usage Documentation 42 | 43 | __Handles__ a is very simple templating language that consists of a single primitive, the "tag". 44 | A tag starts with two open braces `{{`, followed by a string body, and ends with two closing braces `}}`. 45 | There are three kinds of tags, [Property tags](#property-tags), [Block tags](#block-tags), and [Partial tags](#partial-tags). 46 | 47 | ### Property tags 48 | 49 | A property tag is used to access properties passed into the template engine and insert them into the resulting string in-place of the property tag. 50 | Values accessed by a property tag must be of type `String`, `Int`, or `Float`, or it will result in a runtime error. 51 | Accessing a property which was not passed into the template engine will result in a runtime error. 52 | 53 | A property tag can refer to a nested property with `.` separating keys in nested dicts. 54 | 55 | ```handlebars 56 | {{some.property.path}} 57 | ``` 58 | 59 | A property can also refer to the current context element by passing a single `.`. 60 | 61 | ```handlebars 62 | {{.}} 63 | ``` 64 | 65 | ### Block tags 66 | 67 | A block tag is used to temporarily alter the behavior of the templating engine. 68 | Each block tag have two variants; A start tag indicated by a leading `#` and a stop tag indicated by a leading `/`. 69 | A blocks start tag accepts a property accessor, while the end tag does not. 70 | 71 | #### if 72 | 73 | `if` blocks are used to conditionally _include_ parts of a templated based on the value of a property. 74 | Values accessed by an if block must be of type `Bool` or it will result in a runtime error. 75 | Accessing a property which was not passed into the template engine will result in a runtime error. 76 | 77 | ```handlebars 78 | {{#if some.prop}} 79 | {{name}} 80 | {{/if}} 81 | ``` 82 | 83 | #### unless 84 | 85 | `unless` blocks are used to conditionally _exclude_ parts of a templated based on the value of a property. 86 | Values accessed by an `unless` block must be of type `Bool` or it will result in a runtime error. 87 | Accessing a property which was not passed into the template engine will result in a runtime error. 88 | 89 | ```handlebars 90 | {{#unless some.prop}} 91 | {{name}} 92 | {{/unless}} 93 | ``` 94 | 95 | #### each 96 | 97 | `each` blocks are used to include a part of a templated zero or more times. 98 | Values accessed by an `each` block must be of type `List` or it will result in a runtime error. 99 | Accessing a property which was not passed into the template engine will result in a runtime error. 100 | The context of which properties are resolved while inside the each block will be scoped to the current item from the list. 101 | 102 | ```handlebars 103 | {{#each some.prop}} 104 | {{name}} 105 | {{/each}} 106 | ``` 107 | 108 | ### Partial tags 109 | 110 | A partial tag is used to include other templates in-place of the partial tag. 111 | Values accessed by a partial tag can be of any type and will be passed to the partial as the context of which properties are resolved against while inside the partial template. 112 | Accessing a property which was not passed into the template engine will result in a runtime error. 113 | 114 | ```handlebars 115 | {{>some_template some.prop.path}} 116 | ``` 117 | 118 | ## Development 119 | 120 | The source code is structured in such a way that the compiled output when targeting JS does not use recursion in the main parts of the library (`tokenizer.gleam`, `parser.gleam`, `engine.gleam` & `ctx_utils.gleam`). This prevents the JS engine from throwing "Maximum call stack size exceeded" for regular use-cases. While most JS engines boast about implementing tail call optimization, the Gleam compiler is not yet advanced enough to properly take advantage of this. There for, any changes made to the code will need to be carefully inspected after compiling to JS to make sure that no recursion is introduced. This behavior is likely to change with changes to the Gleam compiler, so whenever a new version of the Gleam compiler is released, this library will need to be recompiled and checked (at least until the Gleam compiler becomes smart enough to properly utilize TCO in JS). 121 | 122 | Latest Gleam compiler version checked: `1.3.2` 123 | 124 | Known recursion that needs to be resolved: 125 | 126 | * Parsing the body of a block in `parser.gleam` 127 | 128 | ### Running in development 129 | 130 | ```sh 131 | gleam test # Test Erlang 132 | gleam test -t js # Test Nodejs 133 | ``` 134 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "handles" 2 | version = "4.0.1" 3 | 4 | description = "Handles is a templating language written in pure Gleam. Heavily inspired by Mustache and Handlebars.js" 5 | licences = ["MIT"] 6 | repository = { type = "github", user = "olian04", repo = "gleam_handles" } 7 | gleam = ">= 1.0.0" 8 | 9 | [dependencies] 10 | gleam_stdlib = ">= 0.38.0 and < 1.0.0" 11 | 12 | [dev-dependencies] 13 | gleeunit = ">= 1.1.2 and < 2.0.0" 14 | 15 | [javascript] 16 | # Generate TypeScript .d.ts files 17 | typescript_declarations = true 18 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "gleam_stdlib", version = "0.39.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2D7DE885A6EA7F1D5015D1698920C9BAF7241102836CE0C3837A4F160128A9C4" }, 6 | { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, 7 | ] 8 | 9 | [requirements] 10 | gleam_stdlib = { version = ">= 0.38.0 and < 1.0.0" } 11 | gleeunit = { version = ">= 1.1.2 and < 2.0.0" } 12 | -------------------------------------------------------------------------------- /src/handles.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/list 3 | import gleam/pair 4 | import gleam/result 5 | import gleam/string_builder 6 | import handles/ctx 7 | import handles/error 8 | import handles/internal/engine 9 | import handles/internal/parser 10 | import handles/internal/tokenizer 11 | 12 | pub opaque type Template { 13 | Template(ast: List(parser.AST)) 14 | } 15 | 16 | fn unwrap_template(template: Template) -> List(parser.AST) { 17 | let Template(ast) = template 18 | ast 19 | } 20 | 21 | pub fn prepare(template: String) -> Result(Template, error.TokenizerError) { 22 | tokenizer.run(template) 23 | |> result.try(parser.run(_)) 24 | |> result.map(Template) 25 | } 26 | 27 | pub fn run( 28 | template: Template, 29 | ctx: ctx.Value, 30 | partials: List(#(String, Template)), 31 | ) -> Result(string_builder.StringBuilder, error.RuntimeError) { 32 | partials 33 | |> list.map(pair.map_second(_, unwrap_template)) 34 | |> dict.from_list 35 | |> engine.run(unwrap_template(template), ctx, _) 36 | } 37 | -------------------------------------------------------------------------------- /src/handles/ctx.gleam: -------------------------------------------------------------------------------- 1 | pub type Prop { 2 | Prop(key: String, value: Value) 3 | } 4 | 5 | pub type Value { 6 | Str(value: String) 7 | Int(value: Int) 8 | Float(value: Float) 9 | Bool(value: Bool) 10 | Dict(value: List(Prop)) 11 | List(value: List(Value)) 12 | } 13 | -------------------------------------------------------------------------------- /src/handles/error.gleam: -------------------------------------------------------------------------------- 1 | pub type TokenizerError { 2 | UnbalancedTag(index: Int) 3 | UnbalancedBlock(index: Int) 4 | MissingArgument(index: Int) 5 | MissingBlockKind(index: Int) 6 | MissingPartialId(index: Int) 7 | UnexpectedMultipleArguments(index: Int) 8 | UnexpectedArgument(index: Int) 9 | UnexpectedBlockKind(index: Int) 10 | UnexpectedBlockEnd(index: Int) 11 | } 12 | 13 | pub type RuntimeError { 14 | UnexpectedType( 15 | index: Int, 16 | path: List(String), 17 | got: String, 18 | expected: List(String), 19 | ) 20 | UnknownProperty(index: Int, path: List(String)) 21 | UnknownPartial(index: Int, id: String) 22 | } 23 | -------------------------------------------------------------------------------- /src/handles/format.gleam: -------------------------------------------------------------------------------- 1 | import gleam/int 2 | import gleam/string 3 | import handles/error 4 | 5 | type Position { 6 | Position(index: Int, row: Int, col: Int) 7 | OutOfBounds 8 | } 9 | 10 | fn resolve_position( 11 | input: String, 12 | target_index: Int, 13 | current: Position, 14 | ) -> Position { 15 | case current { 16 | Position(index, row, col) if index == target_index -> 17 | Position(target_index, row, col) 18 | Position(index, row, col) -> 19 | case string.first(input) { 20 | Ok(char) -> 21 | case char { 22 | "\n" -> 23 | resolve_position( 24 | string.drop_left(input, 1), 25 | target_index, 26 | Position(index + 1, row + 1, 0), 27 | ) 28 | _ -> 29 | resolve_position( 30 | string.drop_left(input, 1), 31 | target_index, 32 | Position(index + 1, row, col + 1), 33 | ) 34 | } 35 | Error(_) -> OutOfBounds 36 | } 37 | OutOfBounds -> OutOfBounds 38 | } 39 | } 40 | 41 | fn transform_error(template: String, offset: Int, message: String) { 42 | case resolve_position(template, offset, Position(0, 0, 0)) { 43 | Position(_, row, col) -> 44 | Ok( 45 | message 46 | <> " (row=" 47 | <> int.to_string(row) 48 | <> ", col=" 49 | <> int.to_string(col) 50 | <> ")", 51 | ) 52 | OutOfBounds -> Error(Nil) 53 | } 54 | } 55 | 56 | pub fn format_tokenizer_error( 57 | error: error.TokenizerError, 58 | template: String, 59 | ) -> Result(String, Nil) { 60 | case error { 61 | error.UnbalancedTag(index) -> 62 | transform_error(template, index, "Tag is missing closing braces }}") 63 | error.MissingArgument(index) -> 64 | transform_error(template, index, "Tag is missing an argument") 65 | error.MissingBlockKind(index) -> 66 | transform_error(template, index, "Tag is missing a block kind") 67 | error.MissingPartialId(index) -> 68 | transform_error(template, index, "Tag is missing a partial id") 69 | error.UnexpectedBlockKind(index) -> 70 | transform_error(template, index, "Tag is of an unknown block kind") 71 | error.UnexpectedMultipleArguments(index) -> 72 | transform_error(template, index, "Tag is receiving too many arguments") 73 | error.UnexpectedArgument(index) -> 74 | transform_error(template, index, "Tag is receiving too many arguments") 75 | error.UnbalancedBlock(index) -> 76 | transform_error( 77 | template, 78 | index, 79 | "Tag is a block but is missing its corresponding end tag", 80 | ) 81 | error.UnexpectedBlockEnd(index) -> 82 | transform_error( 83 | template, 84 | index, 85 | "Tag is a block end but is missing its corresponsing opening tag", 86 | ) 87 | } 88 | } 89 | 90 | pub fn format_runtime_error( 91 | error: error.RuntimeError, 92 | template: String, 93 | ) -> Result(String, Nil) { 94 | case error { 95 | error.UnexpectedType(index, path, got, expected) -> 96 | transform_error( 97 | template, 98 | index, 99 | "Unexpected type of property " 100 | <> string.join(path, ".") 101 | <> ", extepced " 102 | <> string.join(expected, " or ") 103 | <> " but found found " 104 | <> got, 105 | ) 106 | error.UnknownProperty(index, path) -> 107 | transform_error( 108 | template, 109 | index, 110 | "Unable to resolve property " <> string.join(path, "."), 111 | ) 112 | error.UnknownPartial(index, id) -> 113 | transform_error(template, index, "Unknown partial " <> id) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/handles/internal/block.gleam: -------------------------------------------------------------------------------- 1 | pub type Kind { 2 | If 3 | Unless 4 | Each 5 | } 6 | -------------------------------------------------------------------------------- /src/handles/internal/ctx_utils.gleam: -------------------------------------------------------------------------------- 1 | import gleam/float 2 | import gleam/int 3 | import gleam/list 4 | import gleam/result 5 | import handles/ctx 6 | import handles/error 7 | 8 | type DrillError { 9 | UnknownProperty 10 | UnexpectedType(String) 11 | } 12 | 13 | fn drill_ctx( 14 | path: List(String), 15 | ctx: ctx.Value, 16 | ) -> Result(ctx.Value, DrillError) { 17 | case path { 18 | [] -> Ok(ctx) 19 | [key, ..rest] -> 20 | case ctx { 21 | ctx.Dict(arr) -> { 22 | case list.find(arr, fn(it) { it.key == key }) { 23 | Ok(ctx.Prop(_, value)) -> drill_ctx(rest, value) 24 | Error(_) -> Error(UnknownProperty) 25 | } 26 | } 27 | ctx.List(_) -> Error(UnexpectedType("List")) 28 | ctx.Str(_) -> Error(UnexpectedType("Str")) 29 | ctx.Int(_) -> Error(UnexpectedType("Int")) 30 | ctx.Float(_) -> Error(UnexpectedType("Float")) 31 | ctx.Bool(_) -> Error(UnexpectedType("Bool")) 32 | } 33 | } 34 | } 35 | 36 | pub fn get(path: List(String), ctx: ctx.Value, index: Int) { 37 | drill_ctx(path, ctx) 38 | |> result.map_error(fn(err) { 39 | case err { 40 | UnexpectedType(got) -> error.UnexpectedType(index, path, got, ["Dict"]) 41 | UnknownProperty -> error.UnknownProperty(index, path) 42 | } 43 | }) 44 | } 45 | 46 | pub fn get_property( 47 | path: List(String), 48 | root_ctx: ctx.Value, 49 | index: Int, 50 | ) -> Result(String, error.RuntimeError) { 51 | get(path, root_ctx, index) 52 | |> result.try(fn(it) { 53 | case it { 54 | ctx.Str(value) -> Ok(value) 55 | ctx.Int(value) -> 56 | value 57 | |> int.to_string 58 | |> Ok 59 | ctx.Float(value) -> 60 | value 61 | |> float.to_string 62 | |> Ok 63 | ctx.List(_) -> 64 | error.UnexpectedType(index, path, "List", ["Str", "Int", "Float"]) 65 | |> Error 66 | ctx.Dict(_) -> 67 | error.UnexpectedType(index, path, "Dict", ["Str", "Int", "Float"]) 68 | |> Error 69 | ctx.Bool(_) -> 70 | error.UnexpectedType(index, path, "Bool", ["Str", "Int", "Float"]) 71 | |> Error 72 | } 73 | }) 74 | } 75 | 76 | pub fn get_list( 77 | path: List(String), 78 | root_ctx: ctx.Value, 79 | index: Int, 80 | ) -> Result(List(ctx.Value), error.RuntimeError) { 81 | get(path, root_ctx, index) 82 | |> result.try(fn(it) { 83 | case it { 84 | ctx.List(value) -> Ok(value) 85 | ctx.Str(_) -> 86 | error.UnexpectedType(index, path, "Str", ["List"]) 87 | |> Error 88 | ctx.Int(_) -> 89 | error.UnexpectedType(index, path, "Int", ["List"]) 90 | |> Error 91 | ctx.Bool(_) -> 92 | error.UnexpectedType(index, path, "Bool", ["List"]) 93 | |> Error 94 | ctx.Float(_) -> 95 | error.UnexpectedType(index, path, "Float", ["List"]) 96 | |> Error 97 | ctx.Dict(_) -> 98 | error.UnexpectedType(index, path, "Dict", ["List"]) 99 | |> Error 100 | } 101 | }) 102 | } 103 | 104 | pub fn get_bool( 105 | path: List(String), 106 | root_ctx: ctx.Value, 107 | index: Int, 108 | ) -> Result(Bool, error.RuntimeError) { 109 | get(path, root_ctx, index) 110 | |> result.try(fn(it) { 111 | case it { 112 | ctx.Bool(value) -> Ok(value) 113 | ctx.List(_) -> 114 | error.UnexpectedType(index, path, "List", ["Bool"]) 115 | |> Error 116 | ctx.Str(_) -> 117 | error.UnexpectedType(index, path, "Str", ["Bool"]) 118 | |> Error 119 | ctx.Int(_) -> 120 | error.UnexpectedType(index, path, "Int", ["Bool"]) 121 | |> Error 122 | ctx.Float(_) -> 123 | error.UnexpectedType(index, path, "Float", ["Bool"]) 124 | |> Error 125 | ctx.Dict(_) -> 126 | error.UnexpectedType(index, path, "Dict", ["Bool"]) 127 | |> Error 128 | } 129 | }) 130 | } 131 | -------------------------------------------------------------------------------- /src/handles/internal/engine.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/list 3 | import gleam/string_builder 4 | import handles/ctx 5 | import handles/error 6 | import handles/internal/block 7 | import handles/internal/ctx_utils 8 | import handles/internal/parser 9 | 10 | type Action { 11 | SetCtx(ctx.Value) 12 | RunAst(List(parser.AST)) 13 | } 14 | 15 | fn eval( 16 | actions: List(Action), 17 | ctx: ctx.Value, 18 | partials: dict.Dict(String, List(parser.AST)), 19 | builder: string_builder.StringBuilder, 20 | ) -> Result(string_builder.StringBuilder, error.RuntimeError) { 21 | case actions { 22 | [] -> Ok(builder) 23 | [SetCtx(new_ctx), ..rest_action] -> 24 | eval(rest_action, new_ctx, partials, builder) 25 | [RunAst([]), ..rest_action] -> eval(rest_action, ctx, partials, builder) 26 | [RunAst([parser.Constant(_, value), ..rest_ast]), ..rest_action] -> 27 | [RunAst(rest_ast), ..rest_action] 28 | |> eval(ctx, partials, string_builder.append(builder, value)) 29 | [RunAst([parser.Property(index, path), ..rest_ast]), ..rest_action] -> 30 | case ctx_utils.get_property(path, ctx, index) { 31 | Error(err) -> Error(err) 32 | Ok(value) -> 33 | [RunAst(rest_ast), ..rest_action] 34 | |> eval(ctx, partials, string_builder.append(builder, value)) 35 | } 36 | [RunAst([parser.Partial(index, id, path), ..rest_ast]), ..rest_action] -> 37 | case dict.get(partials, id) { 38 | Error(_) -> 39 | error.UnknownPartial(index, id) 40 | |> Error 41 | Ok(partial_ast) -> 42 | case ctx_utils.get(path, ctx, index) { 43 | Error(err) -> Error(err) 44 | Ok(inner_ctx) -> 45 | [ 46 | SetCtx(inner_ctx), 47 | RunAst(partial_ast), 48 | SetCtx(ctx), 49 | RunAst(rest_ast), 50 | ..rest_action 51 | ] 52 | |> eval(ctx, partials, builder) 53 | } 54 | } 55 | [ 56 | RunAst([ 57 | parser.Block(start_index, _, block.If, path, children), 58 | ..rest_ast 59 | ]), 60 | ..rest_action 61 | ] -> 62 | case ctx_utils.get_bool(path, ctx, start_index) { 63 | Error(err) -> Error(err) 64 | Ok(False) -> 65 | [RunAst(rest_ast), ..rest_action] 66 | |> eval(ctx, partials, builder) 67 | Ok(True) -> 68 | [RunAst(children), RunAst(rest_ast), ..rest_action] 69 | |> eval(ctx, partials, builder) 70 | } 71 | [ 72 | RunAst([ 73 | parser.Block(start_index, _, block.Unless, path, children), 74 | ..rest_ast 75 | ]), 76 | ..rest_action 77 | ] -> 78 | case ctx_utils.get_bool(path, ctx, start_index) { 79 | Error(err) -> Error(err) 80 | Ok(True) -> 81 | [RunAst(rest_ast), ..rest_action] 82 | |> eval(ctx, partials, builder) 83 | Ok(False) -> 84 | [RunAst(children), RunAst(rest_ast), ..rest_action] 85 | |> eval(ctx, partials, builder) 86 | } 87 | [ 88 | RunAst([ 89 | parser.Block(start_index, _, block.Each, path, children), 90 | ..rest_ast 91 | ]), 92 | ..rest_action 93 | ] -> 94 | case ctx_utils.get_list(path, ctx, start_index) { 95 | Error(err) -> Error(err) 96 | Ok([]) -> 97 | [RunAst(rest_ast), ..rest_action] 98 | |> eval(ctx, partials, builder) 99 | Ok(ctxs) -> 100 | ctxs 101 | |> list.flat_map(fn(new_ctx) { [SetCtx(new_ctx), RunAst(children)] }) 102 | |> list.append([SetCtx(ctx), RunAst(rest_ast), ..rest_action]) 103 | |> eval(ctx, partials, builder) 104 | } 105 | } 106 | } 107 | 108 | pub fn run( 109 | ast: List(parser.AST), 110 | ctx: ctx.Value, 111 | partials: dict.Dict(String, List(parser.AST)), 112 | ) -> Result(string_builder.StringBuilder, error.RuntimeError) { 113 | eval([RunAst(ast)], ctx, partials, string_builder.new()) 114 | } 115 | -------------------------------------------------------------------------------- /src/handles/internal/parser.gleam: -------------------------------------------------------------------------------- 1 | import gleam/list 2 | import gleam/result 3 | import handles/error 4 | import handles/internal/block 5 | import handles/internal/tokenizer 6 | 7 | pub type AST { 8 | Constant(index: Int, value: String) 9 | Property(index: Int, path: List(String)) 10 | Partial(index: Int, id: String, path: List(String)) 11 | Block( 12 | start_index: Int, 13 | end_index: Int, 14 | kind: block.Kind, 15 | path: List(String), 16 | children: List(AST), 17 | ) 18 | } 19 | 20 | type ParseResult { 21 | EOF(List(AST)) 22 | BlockEnd( 23 | index: Int, 24 | kind: block.Kind, 25 | children: List(AST), 26 | rest: List(tokenizer.Token), 27 | ) 28 | } 29 | 30 | fn parse( 31 | ast: List(AST), 32 | tokens: List(tokenizer.Token), 33 | ) -> Result(ParseResult, error.TokenizerError) { 34 | case tokens { 35 | [] -> Ok(EOF(list.reverse(ast))) 36 | [tokenizer.BlockEnd(index, kind), ..tail] -> 37 | Ok(BlockEnd(index, kind, list.reverse(ast), tail)) 38 | 39 | [tokenizer.Constant(index, value), ..tail] -> 40 | [Constant(index, value), ..ast] 41 | |> parse(tail) 42 | [tokenizer.Property(index, path), ..tail] -> 43 | [Property(index, path), ..ast] 44 | |> parse(tail) 45 | [tokenizer.Partial(index, id, value), ..tail] -> 46 | [Partial(index, id, value), ..ast] 47 | |> parse(tail) 48 | 49 | [tokenizer.BlockStart(start_index, start_kind, path), ..tail] -> 50 | case parse([], tail) { 51 | Error(err) -> Error(err) 52 | Ok(EOF(_)) -> 53 | start_index 54 | |> error.UnbalancedBlock 55 | |> Error 56 | Ok(BlockEnd(end_index, end_kind, children, rest)) 57 | if end_kind == start_kind 58 | -> 59 | [Block(start_index, end_index, start_kind, path, children), ..ast] 60 | |> parse(rest) 61 | Ok(BlockEnd(index, _, _, _)) -> 62 | index 63 | |> error.UnexpectedBlockEnd 64 | |> Error 65 | } 66 | } 67 | } 68 | 69 | pub fn run( 70 | tokens: List(tokenizer.Token), 71 | ) -> Result(List(AST), error.TokenizerError) { 72 | parse([], tokens) 73 | |> result.try(fn(it) { 74 | case it { 75 | EOF(ast) -> Ok(ast) 76 | BlockEnd(index, _, _, _) -> 77 | index 78 | |> error.UnexpectedBlockEnd 79 | |> Error 80 | } 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /src/handles/internal/tokenizer.gleam: -------------------------------------------------------------------------------- 1 | import gleam/list 2 | import gleam/pair 3 | import gleam/result 4 | import gleam/string 5 | import handles/error 6 | import handles/internal/block 7 | 8 | pub type Token { 9 | Constant(index: Int, value: String) 10 | Property(index: Int, path: List(String)) 11 | Partial(index: Int, id: String, path: List(String)) 12 | BlockStart(index: Int, kind: block.Kind, path: List(String)) 13 | BlockEnd(index: Int, kind: block.Kind) 14 | } 15 | 16 | type Action { 17 | AddToken(token: Token, new_index: Int, rest_of_input: String) 18 | Stop(error.TokenizerError) 19 | Done 20 | } 21 | 22 | /// {{ 23 | const length_of_open_tag_syntax = 2 24 | 25 | /// {{#}} or {{/}} or {{>}} 26 | const length_of_block_syntax = 5 27 | 28 | /// {{}} 29 | const length_of_property_syntax = 4 30 | 31 | fn split_body(body: String) -> List(String) { 32 | body 33 | |> string.trim 34 | |> string.split(" ") 35 | |> list.filter(fn(it) { 36 | it 37 | |> string.trim 38 | |> string.length 39 | > 0 40 | }) 41 | } 42 | 43 | fn split_arg(arg: String) -> List(String) { 44 | case arg |> string.trim { 45 | "." -> [] 46 | arg -> string.split(arg, ".") 47 | } 48 | } 49 | 50 | fn capture_tag_body( 51 | input: String, 52 | index: Int, 53 | ) -> Result(#(String, String), error.TokenizerError) { 54 | input 55 | |> string.split_once("}}") 56 | |> result.map_error(fn(_) { 57 | index + length_of_open_tag_syntax 58 | |> error.UnbalancedTag 59 | }) 60 | } 61 | 62 | fn stop(index: Int, to_error: fn(Int) -> error.TokenizerError) -> Action { 63 | index + length_of_open_tag_syntax 64 | |> to_error 65 | |> Stop 66 | } 67 | 68 | fn add_block_sized_token( 69 | token: Token, 70 | index: Int, 71 | consumed: String, 72 | rest: String, 73 | ) -> Action { 74 | AddToken( 75 | token, 76 | index + length_of_block_syntax + string.length(consumed), 77 | rest, 78 | ) 79 | } 80 | 81 | fn tokenize(input: String, index: Int) -> Action { 82 | case input { 83 | "" -> Done 84 | "{{>" <> rest -> 85 | case capture_tag_body(rest, index) { 86 | Error(err) -> Stop(err) 87 | Ok(#(body, rest)) -> 88 | case split_body(body) { 89 | [] -> stop(index, error.MissingPartialId) 90 | [_] -> stop(index, error.MissingArgument) 91 | [id, arg] -> 92 | Partial(index + length_of_open_tag_syntax, id, split_arg(arg)) 93 | |> add_block_sized_token(index, body, rest) 94 | _ -> stop(index, error.UnexpectedMultipleArguments) 95 | } 96 | } 97 | 98 | "{{#" <> rest -> 99 | case capture_tag_body(rest, index) { 100 | Error(err) -> Stop(err) 101 | Ok(#(body, rest)) -> 102 | case split_body(body) { 103 | [] -> stop(index, error.MissingBlockKind) 104 | [_] -> stop(index, error.MissingArgument) 105 | ["if", arg] -> 106 | BlockStart( 107 | index + length_of_open_tag_syntax, 108 | block.If, 109 | split_arg(arg), 110 | ) 111 | |> add_block_sized_token(index, body, rest) 112 | ["unless", arg] -> 113 | BlockStart( 114 | index + length_of_open_tag_syntax, 115 | block.Unless, 116 | split_arg(arg), 117 | ) 118 | |> add_block_sized_token(index, body, rest) 119 | ["each", arg] -> 120 | BlockStart( 121 | index + length_of_open_tag_syntax, 122 | block.Each, 123 | split_arg(arg), 124 | ) 125 | |> add_block_sized_token(index, body, rest) 126 | [_, _] -> stop(index, error.UnexpectedBlockKind) 127 | _ -> stop(index, error.UnexpectedMultipleArguments) 128 | } 129 | } 130 | 131 | "{{/" <> rest -> 132 | case capture_tag_body(rest, index) { 133 | Error(err) -> Stop(err) 134 | Ok(#(body, rest)) -> 135 | case split_body(body) { 136 | [] -> stop(index, error.MissingBlockKind) 137 | [_, _] -> stop(index, error.UnexpectedArgument) 138 | ["if"] -> 139 | BlockEnd(index + length_of_open_tag_syntax, block.If) 140 | |> add_block_sized_token(index, body, rest) 141 | ["unless"] -> 142 | BlockEnd(index + length_of_open_tag_syntax, block.Unless) 143 | |> add_block_sized_token(index, body, rest) 144 | ["each"] -> 145 | BlockEnd(index + length_of_open_tag_syntax, block.Each) 146 | |> add_block_sized_token(index, body, rest) 147 | [_] -> stop(index, error.UnexpectedBlockKind) 148 | _ -> stop(index, error.UnexpectedArgument) 149 | } 150 | } 151 | 152 | "{{" <> rest -> 153 | case capture_tag_body(rest, index) { 154 | Error(err) -> Stop(err) 155 | Ok(#(body, rest)) -> 156 | case split_body(body) { 157 | [] -> stop(index, error.MissingArgument) 158 | [arg] -> 159 | AddToken( 160 | Property(index + length_of_open_tag_syntax, split_arg(arg)), 161 | index + length_of_property_syntax + string.length(body), 162 | rest, 163 | ) 164 | _ -> stop(index, error.UnexpectedMultipleArguments) 165 | } 166 | } 167 | _ -> 168 | case 169 | input 170 | |> string.split_once("{{") 171 | |> result.map(pair.map_second(_, fn(it) { "{{" <> it })) 172 | { 173 | Ok(#(str, rest)) -> 174 | AddToken(Constant(index, str), index + string.length(str), rest) 175 | _ -> AddToken(Constant(index, input), index + string.length(input), "") 176 | } 177 | } 178 | } 179 | 180 | fn do_run( 181 | input: String, 182 | index: Int, 183 | tokens: List(Token), 184 | ) -> Result(List(Token), error.TokenizerError) { 185 | case tokenize(input, index) { 186 | Done -> Ok(list.reverse(tokens)) 187 | Stop(err) -> Error(err) 188 | AddToken(token, index, rest) -> do_run(rest, index, [token, ..tokens]) 189 | } 190 | } 191 | 192 | pub fn run(input: String) -> Result(List(Token), error.TokenizerError) { 193 | do_run(input, 0, []) 194 | } 195 | -------------------------------------------------------------------------------- /test/api_tests/each_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/string_builder 2 | import gleeunit/should 3 | import handles 4 | import handles/ctx 5 | 6 | pub fn each_test() { 7 | handles.prepare("{{#each prop}}yes{{/each}}") 8 | |> should.be_ok 9 | |> handles.run( 10 | ctx.Dict([ctx.Prop("prop", ctx.List([ctx.Int(1), ctx.Int(2), ctx.Int(3)]))]), 11 | [], 12 | ) 13 | |> should.be_ok 14 | |> string_builder.to_string 15 | |> should.equal("yesyesyes") 16 | } 17 | 18 | pub fn each_empty_test() { 19 | handles.prepare("{{#each prop}}yes{{/each}}") 20 | |> should.be_ok 21 | |> handles.run(ctx.Dict([ctx.Prop("prop", ctx.List([]))]), []) 22 | |> should.be_ok 23 | |> string_builder.to_string 24 | |> should.equal("") 25 | } 26 | 27 | pub fn each_current_context_test() { 28 | handles.prepare("{{#each .}}yes{{/each}}") 29 | |> should.be_ok 30 | |> handles.run(ctx.List([ctx.Int(1), ctx.Int(2), ctx.Int(3)]), []) 31 | |> should.be_ok 32 | |> string_builder.to_string 33 | |> should.equal("yesyesyes") 34 | } 35 | -------------------------------------------------------------------------------- /test/api_tests/if_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/string_builder 2 | import gleeunit/should 3 | import handles 4 | import handles/ctx 5 | 6 | pub fn if_truthy_test() { 7 | handles.prepare("{{#if prop}}yes{{/if}}") 8 | |> should.be_ok 9 | |> handles.run(ctx.Dict([ctx.Prop("prop", ctx.Bool(True))]), []) 10 | |> should.be_ok 11 | |> string_builder.to_string 12 | |> should.equal("yes") 13 | } 14 | 15 | pub fn if_falsy_test() { 16 | handles.prepare("{{#if prop}}yes{{/if}}") 17 | |> should.be_ok 18 | |> handles.run(ctx.Dict([ctx.Prop("prop", ctx.Bool(False))]), []) 19 | |> should.be_ok 20 | |> string_builder.to_string 21 | |> should.equal("") 22 | } 23 | 24 | pub fn if_current_context_test() { 25 | handles.prepare("{{#if .}}yes{{/if}}") 26 | |> should.be_ok 27 | |> handles.run(ctx.Bool(True), []) 28 | |> should.be_ok 29 | |> string_builder.to_string 30 | |> should.equal("yes") 31 | } 32 | -------------------------------------------------------------------------------- /test/api_tests/partial_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/string_builder 2 | import gleeunit/should 3 | import handles 4 | import handles/ctx 5 | 6 | pub fn partial_test() { 7 | let hello_template = 8 | handles.prepare("Hello {{.}}!") 9 | |> should.be_ok 10 | 11 | handles.prepare("{{>hello prop}}") 12 | |> should.be_ok 13 | |> handles.run(ctx.Dict([ctx.Prop("prop", ctx.Str("Oliver"))]), [ 14 | #("hello", hello_template), 15 | ]) 16 | |> should.be_ok 17 | |> string_builder.to_string 18 | |> should.equal("Hello Oliver!") 19 | } 20 | 21 | pub fn partial_multiple_test() { 22 | let hello_template = 23 | handles.prepare("Hello {{.}}!") 24 | |> should.be_ok 25 | 26 | handles.prepare("{{>hello prop_a}} {{>hello prop_b}} {{>hello prop_c}}") 27 | |> should.be_ok 28 | |> handles.run( 29 | ctx.Dict([ 30 | ctx.Prop("prop_a", ctx.Str("Knatte")), 31 | ctx.Prop("prop_b", ctx.Str("Fnatte")), 32 | ctx.Prop("prop_c", ctx.Str("Tjatte")), 33 | ]), 34 | [#("hello", hello_template)], 35 | ) 36 | |> should.be_ok 37 | |> string_builder.to_string 38 | |> should.equal("Hello Knatte! Hello Fnatte! Hello Tjatte!") 39 | } 40 | 41 | pub fn partial_nested_test() { 42 | let exclaim_template = 43 | handles.prepare("{{.}}!") 44 | |> should.be_ok 45 | 46 | let hello_template = 47 | handles.prepare("Hello {{>exclaim .}}") 48 | |> should.be_ok 49 | 50 | handles.prepare("{{>hello prop}}") 51 | |> should.be_ok 52 | |> handles.run(ctx.Dict([ctx.Prop("prop", ctx.Str("Oliver"))]), [ 53 | #("hello", hello_template), 54 | #("exclaim", exclaim_template), 55 | ]) 56 | |> should.be_ok 57 | |> string_builder.to_string 58 | |> should.equal("Hello Oliver!") 59 | } 60 | -------------------------------------------------------------------------------- /test/api_tests/property_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/string_builder 2 | import gleeunit/should 3 | import handles 4 | import handles/ctx 5 | 6 | pub fn property_string_test() { 7 | handles.prepare("Hello {{name}}!") 8 | |> should.be_ok 9 | |> handles.run(ctx.Dict([ctx.Prop("name", ctx.Str("Oliver"))]), []) 10 | |> should.be_ok 11 | |> string_builder.to_string 12 | |> should.equal("Hello Oliver!") 13 | } 14 | 15 | pub fn property_int_test() { 16 | handles.prepare("The answer is {{answer}}!") 17 | |> should.be_ok 18 | |> handles.run(ctx.Dict([ctx.Prop("answer", ctx.Int(42))]), []) 19 | |> should.be_ok 20 | |> string_builder.to_string 21 | |> should.equal("The answer is 42!") 22 | } 23 | 24 | pub fn property_float_test() { 25 | handles.prepare("π = {{pi}}!") 26 | |> should.be_ok 27 | |> handles.run(ctx.Dict([ctx.Prop("pi", ctx.Float(3.14))]), []) 28 | |> should.be_ok 29 | |> string_builder.to_string 30 | |> should.equal("π = 3.14!") 31 | } 32 | -------------------------------------------------------------------------------- /test/api_tests/unless_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/string_builder 2 | import gleeunit/should 3 | import handles 4 | import handles/ctx 5 | 6 | pub fn unless_truthy_test() { 7 | handles.prepare("{{#unless prop}}yes{{/unless}}") 8 | |> should.be_ok 9 | |> handles.run(ctx.Dict([ctx.Prop("prop", ctx.Bool(True))]), []) 10 | |> should.be_ok 11 | |> string_builder.to_string 12 | |> should.equal("") 13 | } 14 | 15 | pub fn unless_falsy_test() { 16 | handles.prepare("{{#unless prop}}yes{{/unless}}") 17 | |> should.be_ok 18 | |> handles.run(ctx.Dict([ctx.Prop("prop", ctx.Bool(False))]), []) 19 | |> should.be_ok 20 | |> string_builder.to_string 21 | |> should.equal("yes") 22 | } 23 | 24 | pub fn unless_current_context_test() { 25 | handles.prepare("{{#unless .}}yes{{/unless}}") 26 | |> should.be_ok 27 | |> handles.run(ctx.Bool(False), []) 28 | |> should.be_ok 29 | |> string_builder.to_string 30 | |> should.equal("yes") 31 | } 32 | -------------------------------------------------------------------------------- /test/handles_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | 3 | pub fn main() { 4 | gleeunit.main() 5 | } 6 | -------------------------------------------------------------------------------- /test/unit_tests/ctx_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/iterator 2 | import gleeunit/should 3 | import handles/ctx 4 | import handles/internal/ctx_utils 5 | 6 | const expected_string = "expected" 7 | 8 | fn gen_levels(levels_to_go: Int, curr: ctx.Value) -> ctx.Value { 9 | case levels_to_go { 10 | 0 -> curr 11 | _ -> gen_levels(levels_to_go - 1, ctx.Dict([ctx.Prop("prop", curr)])) 12 | } 13 | } 14 | 15 | pub fn drill_shallow_test() { 16 | ctx.Dict([ctx.Prop("prop", ctx.Str(expected_string))]) 17 | |> ctx_utils.get(["prop"], _, 0) 18 | |> should.be_ok 19 | |> should.equal(ctx.Str(expected_string)) 20 | } 21 | 22 | pub fn drill_deep_test() { 23 | let depth = 10_000 24 | iterator.repeat("prop") 25 | |> iterator.take(depth) 26 | |> iterator.to_list 27 | |> ctx_utils.get(gen_levels(depth, ctx.Str(expected_string)), 0) 28 | |> should.be_ok 29 | |> should.equal(ctx.Str(expected_string)) 30 | } 31 | 32 | pub fn get_property_success_test() { 33 | let ctx = 34 | ctx.Dict([ 35 | ctx.Prop("name", ctx.Str("Alice")), 36 | ctx.Prop("age", ctx.Int(30)), 37 | ctx.Prop("height", ctx.Float(5.9)), 38 | ]) 39 | ctx_utils.get_property(["name"], ctx, 0) 40 | |> should.be_ok 41 | |> should.equal("Alice") 42 | 43 | ctx_utils.get_property(["age"], ctx, 0) 44 | |> should.be_ok 45 | |> should.equal("30") 46 | 47 | ctx_utils.get_property(["height"], ctx, 0) 48 | |> should.be_ok 49 | |> should.equal("5.9") 50 | } 51 | 52 | pub fn get_property_error_test() { 53 | let ctx = ctx.Dict([ctx.Prop("name", ctx.Str("Alice"))]) 54 | ctx_utils.get_property(["unknown"], ctx, 0) 55 | |> should.be_error 56 | 57 | ctx_utils.get_property(["name", "subprop"], ctx, 0) 58 | |> should.be_error 59 | } 60 | 61 | pub fn get_list_success_test() { 62 | let ctx = 63 | ctx.Dict([ctx.Prop("items", ctx.List([ctx.Str("item1"), ctx.Str("item2")]))]) 64 | ctx_utils.get_list(["items"], ctx, 0) 65 | |> should.be_ok 66 | } 67 | 68 | pub fn get_list_error_test() { 69 | let ctx = ctx.Dict([ctx.Prop("name", ctx.Str("Alice"))]) 70 | ctx_utils.get_list(["name"], ctx, 0) 71 | |> should.be_error 72 | } 73 | 74 | pub fn get_bool_success_test() { 75 | let ctx = ctx.Dict([ctx.Prop("active", ctx.Bool(True))]) 76 | ctx_utils.get_bool(["active"], ctx, 0) 77 | |> should.be_ok 78 | } 79 | 80 | pub fn get_bool_error_test() { 81 | let ctx = ctx.Dict([ctx.Prop("name", ctx.Str("Alice"))]) 82 | ctx_utils.get_bool(["name"], ctx, 0) 83 | |> should.be_error 84 | } 85 | -------------------------------------------------------------------------------- /test/unit_tests/engine_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/string_builder 3 | import gleeunit/should 4 | import handles/ctx 5 | import handles/internal/block 6 | import handles/internal/engine 7 | import handles/internal/parser 8 | 9 | pub fn hello_world_test() { 10 | [parser.Constant(0, "Hello World")] 11 | |> engine.run(ctx.Dict([]), dict.new()) 12 | |> should.be_ok 13 | |> string_builder.to_string 14 | |> should.equal("Hello World") 15 | } 16 | 17 | pub fn hello_name_test() { 18 | [parser.Constant(0, "Hello "), parser.Property(0, ["name"])] 19 | |> engine.run(ctx.Dict([ctx.Prop("name", ctx.Str("Oliver"))]), dict.new()) 20 | |> should.be_ok 21 | |> string_builder.to_string 22 | |> should.equal("Hello Oliver") 23 | } 24 | 25 | pub fn self_tag_test() { 26 | [parser.Property(0, [])] 27 | |> engine.run(ctx.Str("Hello"), dict.new()) 28 | |> should.be_ok 29 | |> string_builder.to_string 30 | |> should.equal("Hello") 31 | } 32 | 33 | pub fn nested_property_test() { 34 | [parser.Property(0, ["foo", "bar"])] 35 | |> engine.run( 36 | ctx.Dict([ctx.Prop("foo", ctx.Dict([ctx.Prop("bar", ctx.Int(42))]))]), 37 | dict.new(), 38 | ) 39 | |> should.be_ok 40 | |> string_builder.to_string 41 | |> should.equal("42") 42 | } 43 | 44 | pub fn truthy_if_test() { 45 | [parser.Block(0, 0, block.If, ["bool"], [parser.Property(0, ["foo", "bar"])])] 46 | |> engine.run( 47 | ctx.Dict([ 48 | ctx.Prop("foo", ctx.Dict([ctx.Prop("bar", ctx.Int(42))])), 49 | ctx.Prop("bool", ctx.Bool(True)), 50 | ]), 51 | dict.new(), 52 | ) 53 | |> should.be_ok 54 | |> string_builder.to_string 55 | |> should.equal("42") 56 | } 57 | 58 | pub fn falsy_if_test() { 59 | [parser.Block(0, 0, block.If, ["bool"], [parser.Property(0, ["foo", "bar"])])] 60 | |> engine.run(ctx.Dict([ctx.Prop("bool", ctx.Bool(False))]), dict.new()) 61 | |> should.be_ok 62 | |> string_builder.to_string 63 | |> should.equal("") 64 | } 65 | 66 | pub fn truthy_unless_test() { 67 | [ 68 | parser.Block(0, 0, block.Unless, ["bool"], [ 69 | parser.Property(0, ["foo", "bar"]), 70 | ]), 71 | ] 72 | |> engine.run(ctx.Dict([ctx.Prop("bool", ctx.Bool(True))]), dict.new()) 73 | |> should.be_ok 74 | |> string_builder.to_string 75 | |> should.equal("") 76 | } 77 | 78 | pub fn falsy_unless_test() { 79 | [ 80 | parser.Block(0, 0, block.Unless, ["bool"], [ 81 | parser.Property(0, ["foo", "bar"]), 82 | ]), 83 | ] 84 | |> engine.run( 85 | ctx.Dict([ 86 | ctx.Prop("foo", ctx.Dict([ctx.Prop("bar", ctx.Int(42))])), 87 | ctx.Prop("bool", ctx.Bool(False)), 88 | ]), 89 | dict.new(), 90 | ) 91 | |> should.be_ok 92 | |> string_builder.to_string 93 | |> should.equal("42") 94 | } 95 | 96 | pub fn each_test() { 97 | [ 98 | parser.Constant(0, "They are "), 99 | parser.Block(0, 0, block.Each, ["list"], [ 100 | parser.Property(0, ["name"]), 101 | parser.Constant(0, ", "), 102 | ]), 103 | parser.Constant(0, "and Kalle"), 104 | ] 105 | |> engine.run( 106 | ctx.Dict([ 107 | ctx.Prop( 108 | "list", 109 | ctx.List([ 110 | ctx.Dict([ctx.Prop("name", ctx.Str("Knatte"))]), 111 | ctx.Dict([ctx.Prop("name", ctx.Str("Fnatte"))]), 112 | ctx.Dict([ctx.Prop("name", ctx.Str("Tjatte"))]), 113 | ]), 114 | ), 115 | ]), 116 | dict.new(), 117 | ) 118 | |> should.be_ok 119 | |> string_builder.to_string 120 | |> should.equal("They are Knatte, Fnatte, Tjatte, and Kalle") 121 | } 122 | 123 | pub fn empty_each_test() { 124 | [ 125 | parser.Block(0, 0, block.Each, ["list"], [ 126 | parser.Property(0, ["name"]), 127 | parser.Constant(0, ", "), 128 | ]), 129 | ] 130 | |> engine.run(ctx.Dict([ctx.Prop("list", ctx.List([]))]), dict.new()) 131 | |> should.be_ok 132 | |> string_builder.to_string 133 | |> should.equal("") 134 | } 135 | -------------------------------------------------------------------------------- /test/unit_tests/format_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit/should 2 | import handles/format 3 | import handles/internal/tokenizer 4 | 5 | pub fn unexpected_token_test() { 6 | let template = "{{foo}d" 7 | tokenizer.run(template) 8 | |> should.be_error 9 | |> format.format_tokenizer_error(template) 10 | |> should.be_ok 11 | |> should.equal("Tag is missing closing braces }} (row=0, col=2)") 12 | } 13 | 14 | pub fn unexpected_end_of_template_test() { 15 | let template = "{{foo}" 16 | tokenizer.run(template) 17 | |> should.be_error 18 | |> format.format_tokenizer_error(template) 19 | |> should.be_ok 20 | |> should.equal("Tag is missing closing braces }} (row=0, col=2)") 21 | } 22 | -------------------------------------------------------------------------------- /test/unit_tests/parser_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit/should 2 | import handles/error 3 | import handles/internal/block 4 | import handles/internal/parser 5 | import handles/internal/tokenizer 6 | 7 | pub fn no_tokens_test() { 8 | [] 9 | |> parser.run 10 | |> should.be_ok 11 | |> should.equal([]) 12 | } 13 | 14 | pub fn one_constant_test() { 15 | [tokenizer.Constant(0, "Hello World")] 16 | |> parser.run 17 | |> should.be_ok 18 | |> should.equal([parser.Constant(0, "Hello World")]) 19 | } 20 | 21 | pub fn one_property_test() { 22 | [tokenizer.Property(0, ["foo", "bar"])] 23 | |> parser.run 24 | |> should.be_ok 25 | |> should.equal([parser.Property(0, ["foo", "bar"])]) 26 | } 27 | 28 | pub fn self_tag_test() { 29 | [tokenizer.Property(0, [])] 30 | |> parser.run 31 | |> should.be_ok 32 | |> should.equal([parser.Property(0, [])]) 33 | } 34 | 35 | pub fn one_ifblock_test() { 36 | [ 37 | tokenizer.BlockStart(0, block.If, ["bar", "biz"]), 38 | tokenizer.BlockEnd(0, block.If), 39 | ] 40 | |> parser.run 41 | |> should.be_ok 42 | |> should.equal([parser.Block(0, 0, block.If, ["bar", "biz"], [])]) 43 | } 44 | 45 | pub fn one_unlessblock_test() { 46 | [ 47 | tokenizer.BlockStart(0, block.Unless, ["bar", "biz"]), 48 | tokenizer.BlockEnd(0, block.Unless), 49 | ] 50 | |> parser.run 51 | |> should.be_ok 52 | |> should.equal([parser.Block(0, 0, block.Unless, ["bar", "biz"], [])]) 53 | } 54 | 55 | pub fn one_eachblock_test() { 56 | [ 57 | tokenizer.BlockStart(0, block.Each, ["bar", "biz"]), 58 | tokenizer.BlockEnd(0, block.Each), 59 | ] 60 | |> parser.run 61 | |> should.be_ok 62 | |> should.equal([parser.Block(0, 0, block.Each, ["bar", "biz"], [])]) 63 | } 64 | 65 | pub fn missing_if_block_end_test() { 66 | [tokenizer.BlockStart(0, block.If, [])] 67 | |> parser.run 68 | |> should.be_error 69 | |> should.equal(error.UnbalancedBlock(0)) 70 | } 71 | 72 | pub fn missing_unless_block_end_test() { 73 | [tokenizer.BlockStart(0, block.Unless, [])] 74 | |> parser.run 75 | |> should.be_error 76 | |> should.equal(error.UnbalancedBlock(0)) 77 | } 78 | 79 | pub fn missing_each_block_end_test() { 80 | [tokenizer.BlockStart(0, block.Each, [])] 81 | |> parser.run 82 | |> should.be_error 83 | |> should.equal(error.UnbalancedBlock(0)) 84 | } 85 | 86 | pub fn missing_if_block_start_test() { 87 | [tokenizer.BlockEnd(0, block.If)] 88 | |> parser.run 89 | |> should.be_error 90 | |> should.equal(error.UnexpectedBlockEnd(0)) 91 | } 92 | 93 | pub fn missing_unless_block_start_test() { 94 | [tokenizer.BlockEnd(0, block.Unless)] 95 | |> parser.run 96 | |> should.be_error 97 | |> should.equal(error.UnexpectedBlockEnd(0)) 98 | } 99 | 100 | pub fn missing_each_block_start_test() { 101 | [tokenizer.BlockEnd(0, block.Unless)] 102 | |> parser.run 103 | |> should.be_error 104 | |> should.equal(error.UnexpectedBlockEnd(0)) 105 | } 106 | -------------------------------------------------------------------------------- /test/unit_tests/tokenizer_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit/should 2 | import handles/error 3 | import handles/internal/block 4 | import handles/internal/tokenizer 5 | 6 | pub fn empty_string_test() { 7 | "" 8 | |> tokenizer.run 9 | |> should.be_ok 10 | |> should.equal([]) 11 | } 12 | 13 | pub fn property_test() { 14 | "{{foo}}" 15 | |> tokenizer.run 16 | |> should.be_ok 17 | |> should.equal([tokenizer.Property(2, ["foo"])]) 18 | } 19 | 20 | pub fn property_multiple_test() { 21 | "{{foo}} {{bar}}" 22 | |> tokenizer.run 23 | |> should.be_ok 24 | |> should.equal([ 25 | tokenizer.Property(2, ["foo"]), 26 | tokenizer.Constant(7, " "), 27 | tokenizer.Property(10, ["bar"]), 28 | ]) 29 | } 30 | 31 | pub fn property_self_test() { 32 | "{{.}}" 33 | |> tokenizer.run 34 | |> should.be_ok 35 | |> should.equal([tokenizer.Property(2, [])]) 36 | } 37 | 38 | pub fn if_block_test() { 39 | "{{#if prop}}{{/if}}" 40 | |> tokenizer.run 41 | |> should.be_ok 42 | |> should.equal([ 43 | tokenizer.BlockStart(2, block.If, ["prop"]), 44 | tokenizer.BlockEnd(14, block.If), 45 | ]) 46 | } 47 | 48 | pub fn if_block_self_test() { 49 | "{{#if .}}{{/if}}" 50 | |> tokenizer.run 51 | |> should.be_ok 52 | |> should.equal([ 53 | tokenizer.BlockStart(2, block.If, []), 54 | tokenizer.BlockEnd(11, block.If), 55 | ]) 56 | } 57 | 58 | pub fn unless_block_test() { 59 | "{{#unless prop}}{{/unless}}" 60 | |> tokenizer.run 61 | |> should.be_ok 62 | |> should.equal([ 63 | tokenizer.BlockStart(2, block.Unless, ["prop"]), 64 | tokenizer.BlockEnd(18, block.Unless), 65 | ]) 66 | } 67 | 68 | pub fn unless_block_self_test() { 69 | "{{#unless .}}{{/unless}}" 70 | |> tokenizer.run 71 | |> should.be_ok 72 | |> should.equal([ 73 | tokenizer.BlockStart(2, block.Unless, []), 74 | tokenizer.BlockEnd(15, block.Unless), 75 | ]) 76 | } 77 | 78 | pub fn each_block_test() { 79 | "{{#each prop}}{{/each}}" 80 | |> tokenizer.run 81 | |> should.be_ok 82 | |> should.equal([ 83 | tokenizer.BlockStart(2, block.Each, ["prop"]), 84 | tokenizer.BlockEnd(16, block.Each), 85 | ]) 86 | } 87 | 88 | pub fn each_block_self_test() { 89 | "{{#each .}}{{/each}}" 90 | |> tokenizer.run 91 | |> should.be_ok 92 | |> should.equal([ 93 | tokenizer.BlockStart(2, block.Each, []), 94 | tokenizer.BlockEnd(13, block.Each), 95 | ]) 96 | } 97 | 98 | pub fn unexpected_token_test() { 99 | "{{foo}d" 100 | |> tokenizer.run 101 | |> should.be_error 102 | |> should.equal(error.UnbalancedTag(2)) 103 | } 104 | 105 | pub fn unexpected_end_of_template_test() { 106 | "{{foo}" 107 | |> tokenizer.run 108 | |> should.be_error 109 | |> should.equal(error.UnbalancedTag(2)) 110 | } 111 | 112 | pub fn missing_block_argument_test() { 113 | "{{#if}}" 114 | |> tokenizer.run 115 | |> should.be_error 116 | |> should.equal(error.MissingArgument(2)) 117 | } 118 | 119 | pub fn end_block_with_arguments_test() { 120 | "{{/if bar}}" 121 | |> tokenizer.run 122 | |> should.be_error 123 | |> should.equal(error.UnexpectedArgument(2)) 124 | } 125 | 126 | pub fn empty_expression_test() { 127 | "{{}}" 128 | |> tokenizer.run 129 | |> should.be_error 130 | |> should.equal(error.MissingArgument(2)) 131 | } 132 | 133 | pub fn whitespace_test() { 134 | "{{ . }}" 135 | |> tokenizer.run 136 | |> should.be_ok 137 | |> should.equal([tokenizer.Property(2, [])]) 138 | 139 | "{{# if prop }}{{/ if }}" 140 | |> tokenizer.run 141 | |> should.be_ok 142 | |> should.equal([ 143 | tokenizer.BlockStart(2, block.If, ["prop"]), 144 | tokenizer.BlockEnd(42, block.If), 145 | ]) 146 | 147 | "{{> template prop }}" 148 | |> tokenizer.run 149 | |> should.be_ok 150 | |> should.equal([tokenizer.Partial(2, "template", ["prop"])]) 151 | } 152 | -------------------------------------------------------------------------------- /test/user_stories/big_template.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/iterator 3 | import gleeunit/should 4 | import handles/ctx 5 | import handles/internal/engine 6 | import handles/internal/parser 7 | import handles/internal/tokenizer 8 | 9 | const input_context = ctx.Dict( 10 | [ 11 | ctx.Prop( 12 | "knattarna", 13 | ctx.List( 14 | [ 15 | ctx.Dict([ctx.Prop("name", ctx.Str("Knatte"))]), 16 | ctx.Dict([ctx.Prop("name", ctx.Str("Fnatte"))]), 17 | ctx.Dict([ctx.Prop("name", ctx.Str("Tjatte"))]), 18 | ], 19 | ), 20 | ), 21 | ], 22 | ) 23 | 24 | fn generate_template(size: Int, sep: String) { 25 | iterator.repeat("{{#each knattarna}}{{name}}, {{/each}}") 26 | |> iterator.take(size) 27 | |> iterator.fold("", fn(a, b) { a <> sep <> b }) 28 | } 29 | 30 | pub fn tokenizer_test() { 31 | let big_template = generate_template(10_000, " ") 32 | 33 | tokenizer.run(big_template) 34 | |> should.be_ok 35 | } 36 | 37 | pub fn parser_test() { 38 | let big_template = generate_template(10_000, " ") 39 | 40 | tokenizer.run(big_template) 41 | |> should.be_ok 42 | |> parser.run 43 | |> should.be_ok 44 | } 45 | 46 | pub fn engine_test() { 47 | let big_template = generate_template(10_000, " ") 48 | 49 | tokenizer.run(big_template) 50 | |> should.be_ok 51 | |> parser.run 52 | |> should.be_ok 53 | |> engine.run(input_context, dict.new()) 54 | |> should.be_ok 55 | } 56 | -------------------------------------------------------------------------------- /test/user_stories/hello_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/string_builder 3 | import gleeunit/should 4 | import handles/ctx 5 | import handles/internal/engine 6 | import handles/internal/parser 7 | import handles/internal/tokenizer 8 | 9 | const input_template = "Hello {{name}}!" 10 | 11 | const input_context = ctx.Dict([ctx.Prop("name", ctx.Str("Oliver"))]) 12 | 13 | const expected_tokens = [ 14 | tokenizer.Constant(0, "Hello "), tokenizer.Property(8, ["name"]), 15 | tokenizer.Constant(14, "!"), 16 | ] 17 | 18 | const expected_ast = [ 19 | parser.Constant(0, "Hello "), parser.Property(8, ["name"]), 20 | parser.Constant(14, "!"), 21 | ] 22 | 23 | const expected_output = "Hello Oliver!" 24 | 25 | pub fn tokenizer_test() { 26 | tokenizer.run(input_template) 27 | |> should.be_ok 28 | |> should.equal(expected_tokens) 29 | } 30 | 31 | pub fn parser_test() { 32 | parser.run(expected_tokens) 33 | |> should.be_ok 34 | |> should.equal(expected_ast) 35 | } 36 | 37 | pub fn engine_test() { 38 | engine.run(expected_ast, input_context, dict.new()) 39 | |> should.be_ok 40 | |> string_builder.to_string 41 | |> should.equal(expected_output) 42 | } 43 | -------------------------------------------------------------------------------- /test/user_stories/knattarna_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/string_builder 3 | import gleeunit/should 4 | import handles/ctx 5 | import handles/internal/block 6 | import handles/internal/engine 7 | import handles/internal/parser 8 | import handles/internal/tokenizer 9 | 10 | const input_template = "They are {{#each knattarna}}{{name}}, {{/each}}and Kalle" 11 | 12 | const input_context = ctx.Dict( 13 | [ 14 | ctx.Prop( 15 | "knattarna", 16 | ctx.List( 17 | [ 18 | ctx.Dict([ctx.Prop("name", ctx.Str("Knatte"))]), 19 | ctx.Dict([ctx.Prop("name", ctx.Str("Fnatte"))]), 20 | ctx.Dict([ctx.Prop("name", ctx.Str("Tjatte"))]), 21 | ], 22 | ), 23 | ), 24 | ], 25 | ) 26 | 27 | const expected_tokens = [ 28 | tokenizer.Constant(0, "They are "), 29 | tokenizer.BlockStart(11, block.Each, ["knattarna"]), 30 | tokenizer.Property(30, ["name"]), tokenizer.Constant(36, ", "), 31 | tokenizer.BlockEnd(40, block.Each), tokenizer.Constant(47, "and Kalle"), 32 | ] 33 | 34 | const expected_ast = [ 35 | parser.Constant(0, "They are "), 36 | parser.Block( 37 | 11, 38 | 40, 39 | block.Each, 40 | ["knattarna"], 41 | [parser.Property(30, ["name"]), parser.Constant(36, ", ")], 42 | ), parser.Constant(47, "and Kalle"), 43 | ] 44 | 45 | const expected_output = "They are Knatte, Fnatte, Tjatte, and Kalle" 46 | 47 | pub fn tokenizer_test() { 48 | tokenizer.run(input_template) 49 | |> should.be_ok 50 | |> should.equal(expected_tokens) 51 | } 52 | 53 | pub fn parser_test() { 54 | parser.run(expected_tokens) 55 | |> should.be_ok 56 | |> should.equal(expected_ast) 57 | } 58 | 59 | pub fn engine_test() { 60 | engine.run(expected_ast, input_context, dict.new()) 61 | |> should.be_ok 62 | |> string_builder.to_string 63 | |> should.equal(expected_output) 64 | } 65 | -------------------------------------------------------------------------------- /test/user_stories/nested_block_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/string_builder 3 | import gleeunit/should 4 | import handles/ctx 5 | import handles/internal/block 6 | import handles/internal/engine 7 | import handles/internal/parser 8 | import handles/internal/tokenizer 9 | 10 | const input_template = "{{#each outer}}{{#each inner}}{{value}}{{/each}}{{/each}}" 11 | 12 | const input_context = ctx.Dict( 13 | [ 14 | ctx.Prop( 15 | "outer", 16 | ctx.List( 17 | [ 18 | ctx.Dict( 19 | [ 20 | ctx.Prop( 21 | "inner", 22 | ctx.List( 23 | [ 24 | ctx.Dict([ctx.Prop("value", ctx.Int(1))]), 25 | ctx.Dict([ctx.Prop("value", ctx.Int(2))]), 26 | ], 27 | ), 28 | ), 29 | ], 30 | ), 31 | ctx.Dict( 32 | [ 33 | ctx.Prop( 34 | "inner", 35 | ctx.List( 36 | [ 37 | ctx.Dict([ctx.Prop("value", ctx.Int(1))]), 38 | ctx.Dict([ctx.Prop("value", ctx.Int(2))]), 39 | ], 40 | ), 41 | ), 42 | ], 43 | ), 44 | ], 45 | ), 46 | ), 47 | ], 48 | ) 49 | 50 | const expected_tokens = [ 51 | tokenizer.BlockStart(2, block.Each, ["outer"]), 52 | tokenizer.BlockStart(17, block.Each, ["inner"]), 53 | tokenizer.Property(32, ["value"]), tokenizer.BlockEnd(41, block.Each), 54 | tokenizer.BlockEnd(50, block.Each), 55 | ] 56 | 57 | const expected_ast = [ 58 | parser.Block( 59 | 2, 60 | 50, 61 | block.Each, 62 | ["outer"], 63 | [ 64 | parser.Block( 65 | 17, 66 | 41, 67 | block.Each, 68 | ["inner"], 69 | [parser.Property(32, ["value"])], 70 | ), 71 | ], 72 | ), 73 | ] 74 | 75 | const expected_output = "1212" 76 | 77 | pub fn tokenizer_test() { 78 | tokenizer.run(input_template) 79 | |> should.be_ok 80 | |> should.equal(expected_tokens) 81 | } 82 | 83 | pub fn parser_test() { 84 | parser.run(expected_tokens) 85 | |> should.be_ok 86 | |> should.equal(expected_ast) 87 | } 88 | 89 | pub fn engine_test() { 90 | engine.run(expected_ast, input_context, dict.new()) 91 | |> should.be_ok 92 | |> string_builder.to_string 93 | |> should.equal(expected_output) 94 | } 95 | --------------------------------------------------------------------------------