Current section

Files

Jump to
glemplate src glemplate parser.gleam
Raw

src/glemplate/parser.gleam

//// The parser transforms a string input into a template AST.
////
//// ## Template language description
////
//// The template language uses `<%` and `%>` as the tag delimiters. For
//// outputting, the start delimiter `<%=` is used.
////
//// Comments can be inserted with `<%!-- Comment --%>`, they are not emitted to
//// the AST.
////
//// Assigns can be referenced using their name (key in the assigns map). After
//// an assign name, the contents of the assign can be accessed using field or
//// index based access:
////
//// * `foo.bar` accesses field `bar` in assign `foo`.
//// * `foo.0` accesses the first element in assign `foo`.
////
//// The accesses can be chained, e.g. `foo.bar.0.baz`. These access methods
//// work in any place where an assign reference is expected.
////
//// Any other content in the template is emitted as text nodes.
////
//// ### Tags
////
//// #### Output
////
//// ```text
//// <%= foo %>
//// ```
////
//// Emits an output node of the assign `foo`. The contents are encoded using
//// the encoder given in the render call.
////
//// #### Raw output
////
//// ```text
//// <%= raw foo %>
//// ```
////
//// Emits an output node of the assign `foo`. The contents are not encoded.
////
//// #### If
////
//// ```text
//// <% if foo %>
//// ...
//// <% else %>
//// ...
//// <% end %>
//// ```
////
//// Emits an if node. If the assign `foo` is truthy (not `False`), the first
//// nodes are rendered, otherwise the nodes in the `else` block. The `else`
//// block is optional.
////
//// #### For
////
//// ```text
//// <% for bar in foo %>
//// ...
//// <% end %>
//// ```
////
//// Emits a for node. Each item of `foo` is looped through, making it available
//// as the assign `bar` inside the block. The contents of the block are
//// rendered for each item.
////
//// #### Render
////
//// ```text
//// <% render tpl.txt.glemp %>
//// <% render tpl.txt.glemp name: user.name, level: user.level %>
//// ```
////
//// Emits a render node. This will render another template in this place. The
//// listed assigns will be the assigns given to the template. The name of the
//// assign in the child template is on the left, the value is on the right.
import gleam/string_builder
import gleam/list
import gleam/option.{None, Option, Some}
import gleam/result
import gleam/int
import nibble
import nibble/predicates
import glemplate/ast
const tag_start = "<%"
const tag_end = "%>"
const tag_escape_char = "%"
const tag_comment_start = "!--"
const tag_comment_end = "--"
const tag_output = "="
const end_token = "end"
const if_token = "if"
const else_token = "else"
const iter_token = "for"
const iter_oper_token = "in"
const output_raw_token = "raw"
const render_token = "render"
const render_binding_separator = ":"
const render_pair_separator = ","
const access_separator = "."
type IncompleteTag {
If(var: ast.Var, if_true: Option(ast.NodeList))
Iter(over: ast.Var, binding: ast.VarName)
}
type IncompleteAccess {
FieldAccess(field: String)
IndexAccess(index: Int)
}
type State {
State(
emitted: ast.NodeList,
current_text: string_builder.StringBuilder,
current_nodes: ast.NodeList,
current_tag: Option(IncompleteTag),
outer_state: Option(State),
)
}
/// Parse the string into a template with the given name.
pub fn parse_to_template(template: String, name: String) {
use parsed <- result.then(parse(template))
Ok(ast.Template(name: name, nodes: parsed))
}
/// Parse the string into a node list.
pub fn parse(template: String) {
nibble.run(template, base_parser(empty_state()))
|> result.map(fn(state: State) { state.emitted })
}
fn empty_state() {
State(
emitted: [],
current_nodes: [],
current_text: string_builder.new(),
current_tag: None,
outer_state: None,
)
}
fn base_parser(old_state: State) {
nibble.loop(
old_state,
fn(state) {
nibble.one_of([
tag_parser(state)
|> nibble.map(nibble.Continue),
nibble.any()
|> nibble.map(fn(c) {
State(
..state,
current_text: string_builder.append(state.current_text, c),
)
})
|> nibble.map(nibble.Continue),
nibble.eof()
|> nibble.replace(stringify_current_text(state))
|> nibble.map(nibble.Break),
])
},
)
|> nibble.map(fn(state) {
State(
..state,
emitted: [ast.Nodes(state.emitted), ..list.reverse(state.current_nodes)],
current_nodes: [],
current_text: string_builder.new(),
)
})
}
fn tag_parser(state: State) {
use _ <- nibble.then(nibble.string(tag_start))
let state = stringify_current_text(state)
nibble.one_of([
escaped_tag(state),
comment_tag(state),
output_tag(state),
other_tag(state),
])
}
fn escaped_tag(state: State) {
nibble.string(tag_escape_char)
|> nibble.replace(
State(..state, current_nodes: [ast.Text(tag_start), ..state.current_nodes]),
)
}
fn comment_tag(state: State) {
use _ <- nibble.then(nibble.string(tag_comment_start))
nibble.loop(
Nil,
fn(_) {
nibble.one_of([
nibble.string(tag_comment_end <> tag_end)
|> nibble.map(nibble.Break),
nibble.any()
|> nibble.replace(nibble.Continue(Nil)),
])
},
)
|> nibble.replace(state)
}
fn output_tag(state: State) {
use _ <- nibble.then(nibble.string(tag_output))
nibble.one_of([
// Raw output
{
use _ <- nibble.then(nibble.whitespace())
use _ <- nibble.then(nibble.string(output_raw_token))
use _ <- nibble.then(nibble.whitespace())
use var <- nibble.then(variable())
nibble.succeed(
State(
..state,
current_nodes: [
ast.Dynamic(ast.RawOutput(var)),
..state.current_nodes
],
),
)
},
// Normal output
{
use _ <- nibble.then(nibble.whitespace())
use var <- nibble.then(variable())
nibble.succeed(
State(
..state,
current_nodes: [ast.Dynamic(ast.Output(var)), ..state.current_nodes],
),
)
},
])
|> nibble.drop(nibble.whitespace())
|> nibble.drop(nibble.string(tag_end))
}
fn other_tag(state: State) {
use _ <- nibble.then(nibble.whitespace())
nibble.one_of([
if_tag(state),
for_tag(state),
render_tag(state),
else_tag(state),
end_tag(state),
])
|> nibble.drop(nibble.whitespace())
|> nibble.drop(nibble.string(tag_end))
}
fn if_tag(state: State) {
use _ <- nibble.then(nibble.string(if_token))
use _ <- nibble.then(nibble.whitespace())
use var <- nibble.then(variable())
nibble.succeed(
State(
..empty_state(),
current_tag: Some(If(var, None)),
outer_state: Some(state),
),
)
}
fn for_tag(state: State) {
use _ <- nibble.then(nibble.string(iter_token))
use _ <- nibble.then(nibble.whitespace())
use binding <- nibble.then(variable_name())
use _ <- nibble.then(nibble.whitespace())
use _ <- nibble.then(nibble.string(iter_oper_token))
use _ <- nibble.then(nibble.whitespace())
use over <- nibble.then(variable())
nibble.succeed(
State(
..empty_state(),
current_tag: Some(Iter(over, binding)),
outer_state: Some(state),
),
)
}
fn render_tag(state: State) {
use _ <- nibble.then(nibble.string(render_token))
use _ <- nibble.then(nibble.whitespace())
use filename <- nibble.then(filename())
use _ <- nibble.then(nibble.whitespace())
nibble.many(render_binding(), render_separator())
|> nibble.map(fn(bindings) {
State(
..state,
current_nodes: [
ast.Dynamic(ast.Render(filename, bindings)),
..state.current_nodes
],
)
})
}
fn end_tag(state: State) {
use _ <- nibble.then(nibble.string(end_token))
let outer_state =
option.lazy_unwrap(state.outer_state, fn() { empty_state() })
case state.current_tag {
Some(If(var, if_true)) -> {
let #(if_true, state) = case if_true {
Some(if_true_nodes) -> #(if_true_nodes, state)
None -> #(
list.reverse(state.current_nodes),
State(..state, current_nodes: []),
)
}
let if_false = list.reverse(state.current_nodes)
nibble.succeed(
State(
..outer_state,
current_nodes: [
ast.Dynamic(ast.If(var, if_true, if_false)),
..outer_state.current_nodes
],
),
)
}
Some(Iter(over, binding)) ->
nibble.succeed(
State(
..outer_state,
current_nodes: [
ast.Dynamic(ast.Iter(
over,
binding,
list.reverse(state.current_nodes),
)),
..outer_state.current_nodes
],
),
)
_else -> nibble.fail("Expected `if` or `for` tag for `end` tag")
}
}
fn else_tag(state: State) {
use _ <- nibble.then(nibble.string(else_token))
case state.current_tag {
Some(If(var, None)) ->
nibble.succeed(
State(
..state,
current_tag: Some(If(var, Some(list.reverse(state.current_nodes)))),
current_nodes: [],
),
)
_else -> nibble.fail("Expected `if` to match `else` tag")
}
}
fn stringify_current_text(state: State) {
State(
..state,
current_nodes: [
ast.Text(string_builder.to_string(state.current_text)),
..state.current_nodes
],
current_text: string_builder.new(),
)
}
fn variable_name() {
nibble.take_if_and_while(
fn(grapheme) { predicates.is_alphanum(grapheme) || grapheme == "_" },
"a variable name",
)
}
fn field_access() {
nibble.take_if_and_while(
fn(grapheme) { predicates.is_alphanum(grapheme) || grapheme == "_" },
"a field accessor",
)
}
fn index_access() {
nibble.take_if_and_while(predicates.is_digit, "an index accessor")
}
fn accessors() {
use accessors <- nibble.map(nibble.many(
nibble.one_of([field_access(), index_access()]),
nibble.string(access_separator),
))
list.map(
accessors,
fn(accessor) {
case int.parse(accessor) {
Ok(data) -> IndexAccess(data)
Error(_) -> FieldAccess(accessor)
}
},
)
}
fn variable() {
use name <- nibble.then(variable_name())
use accessors <- nibble.then(nibble.one_of([
{
use _ <- nibble.then(nibble.string(access_separator))
use accs <- nibble.then(accessors())
nibble.succeed(accs)
},
nibble.succeed([]),
]))
nibble.succeed(list.fold(
accessors,
ast.Assign(name),
fn(acc, accessor) {
case accessor {
FieldAccess(field) -> ast.FieldAccess(acc, field)
IndexAccess(index) -> ast.IndexAccess(acc, index)
}
},
))
}
fn filename() {
nibble.take_if_and_while(
fn(grapheme) {
predicates.is_alphanum(grapheme) || grapheme == "_" || grapheme == "-" || grapheme == "."
},
"a filename without spaces",
)
}
fn render_binding() {
use to <- nibble.then(variable_name())
use _ <- nibble.then(nibble.string(render_binding_separator))
use _ <- nibble.then(nibble.whitespace())
use from <- nibble.then(variable())
nibble.succeed(#(from, to))
}
fn render_separator() {
use _ <- nibble.then(nibble.string(render_pair_separator))
use _ <- nibble.then(nibble.whitespace())
nibble.succeed(Nil)
}