151 lines
5.2 KiB
Elixir
151 lines
5.2 KiB
Elixir
defmodule BDS.Rendering.LiquidParser do
|
|
@moduledoc """
|
|
Restricted Liquid parser enforcing the `LiquidTagSubset` invariant
|
|
(`specs/template.allium`).
|
|
|
|
Only the tags used by the bundled starter templates are recognized:
|
|
|
|
* `{% if %}` / `{% elsif %}` / `{% else %}` / `{% endif %}`
|
|
* `{% for %}` / `{% endfor %}`
|
|
* `{% assign %}`
|
|
* `{% render 'partial', name: value %}`
|
|
* `{{ object }}` output (and whitespace-stripped variants `{%- -%}` / `{{- -}}`)
|
|
|
|
Any tag outside this subset (`unless`, `case`, `capture`, `raw`, `comment`,
|
|
`cycle`, `tablerow`, `increment`, `decrement`, `liquid`, `echo`, `include`)
|
|
leaves unmatched input and fails the `eos/0` check, producing a parse error.
|
|
|
|
Pass this module as the second argument to `Liquex.parse/2` (and
|
|
`Liquex.parse!/2`) so validation and rendering share the same surface.
|
|
|
|
`validate/1` additionally enforces the `LiquidFilterSubset` invariant: only
|
|
the four standard filters (`escape`, `url_encode`, `default`, `append`) and
|
|
three custom filters (`i18n`, `markdown`, `slugify`) are permitted. Any other
|
|
filter (`upcase`, `date`, `truncate`, `split`, `join`, …) is rejected even
|
|
though Liquex would otherwise apply it as a built-in standard filter.
|
|
|
|
`validate/1` also enforces the `LiquidOperatorSubset` invariant: only the
|
|
`==` and `>` comparison operators are permitted (alongside the `and`/`or`
|
|
logical operators and bare-variable truthiness). Any other comparison
|
|
operator (`!=`, `<`, `>=`, `<=`, `contains`) is rejected even though Liquex
|
|
would otherwise evaluate it.
|
|
"""
|
|
|
|
import NimbleParsec
|
|
|
|
# LiquidFilterSubset invariant (specs/template.allium).
|
|
@allowed_filters ~w(escape url_encode default append i18n markdown slugify)
|
|
|
|
# LiquidOperatorSubset invariant (specs/template.allium).
|
|
@allowed_operators [:==, :>]
|
|
|
|
@doc "The filter names permitted by the `LiquidFilterSubset` invariant."
|
|
@spec allowed_filters() :: [String.t()]
|
|
def allowed_filters, do: @allowed_filters
|
|
|
|
@doc """
|
|
Parses `source` with the restricted tag grammar and enforces the
|
|
`LiquidFilterSubset` invariant.
|
|
|
|
Returns `{:ok, ast}` on success, or `{:error, reason, line}` on a parse error
|
|
or an unsupported filter (mirroring the `Liquex.parse/2` error shape so
|
|
callers can treat both failures uniformly).
|
|
"""
|
|
@spec validate(binary()) :: {:ok, term()} | {:error, term(), non_neg_integer()}
|
|
def validate(source) when is_binary(source) do
|
|
case Liquex.parse(source, __MODULE__) do
|
|
{:ok, ast} ->
|
|
with [] <- unsupported_filters(ast),
|
|
[] <- unsupported_operators(ast) do
|
|
{:ok, ast}
|
|
else
|
|
[{:filter, name} | _] -> {:error, "unsupported filter: #{name}", 0}
|
|
[{:operator, op} | _] -> {:error, "unsupported operator: #{op}", 0}
|
|
end
|
|
|
|
{:error, _reason, _line} = error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@spec unsupported_filters(term()) :: [{:filter, String.t()}]
|
|
defp unsupported_filters(ast) do
|
|
ast
|
|
|> collect_filters()
|
|
|> Enum.uniq()
|
|
|> Enum.reject(&(&1 in @allowed_filters))
|
|
|> Enum.map(&{:filter, &1})
|
|
end
|
|
|
|
@spec unsupported_operators(term()) :: [{:operator, String.t()}]
|
|
defp unsupported_operators(ast) do
|
|
ast
|
|
|> collect_operators()
|
|
|> Enum.uniq()
|
|
|> Enum.reject(&(&1 in @allowed_operators))
|
|
|> Enum.map(&{:operator, operator_to_string(&1)})
|
|
end
|
|
|
|
@spec collect_filters(term()) :: [String.t()]
|
|
defp collect_filters({:filter, [name | rest]}) when is_binary(name) do
|
|
[name | collect_filters(rest)]
|
|
end
|
|
|
|
defp collect_filters(term) when is_tuple(term), do: collect_filters(Tuple.to_list(term))
|
|
defp collect_filters(term) when is_list(term), do: Enum.flat_map(term, &collect_filters/1)
|
|
defp collect_filters(_term), do: []
|
|
|
|
@spec collect_operators(term()) :: [atom()]
|
|
defp collect_operators({:op, op}) when is_atom(op), do: [op]
|
|
defp collect_operators(term) when is_tuple(term), do: collect_operators(Tuple.to_list(term))
|
|
defp collect_operators(term) when is_list(term), do: Enum.flat_map(term, &collect_operators/1)
|
|
defp collect_operators(_term), do: []
|
|
|
|
@spec operator_to_string(atom()) :: String.t()
|
|
defp operator_to_string(:contains), do: "contains"
|
|
defp operator_to_string(op), do: Atom.to_string(op)
|
|
|
|
@tags [
|
|
Liquex.Tag.AssignTag,
|
|
Liquex.Tag.ForTag,
|
|
Liquex.Tag.IfTag,
|
|
Liquex.Tag.RenderTag,
|
|
Liquex.Tag.ObjectTag
|
|
]
|
|
|
|
tags_parser = Enum.map(@tags, &tag(&1.parse(), {:tag, &1}))
|
|
|
|
# Ensure the tags are loaded into scope, otherwise function_exported? will
|
|
# return false.
|
|
Enum.each(@tags, &Code.ensure_loaded!/1)
|
|
|
|
liquid_tags_parser =
|
|
@tags
|
|
|> Enum.filter(&function_exported?(&1, :parse_liquid_tag, 0))
|
|
|> Enum.map(&tag(&1.parse_liquid_tag(), {:tag, &1}))
|
|
|> choice()
|
|
|
|
# Special case for leading spaces before `{%-` and `{{-`.
|
|
leading_whitespace =
|
|
empty()
|
|
# credo:disable-for-lines:1
|
|
|> Liquex.Parser.Literal.whitespace(1)
|
|
|> lookahead(choice([string("{%-"), string("{{-")]))
|
|
|> ignore()
|
|
|
|
base =
|
|
choice(
|
|
tags_parser ++
|
|
[
|
|
# credo:disable-for-lines:2
|
|
Liquex.Parser.Literal.text(),
|
|
leading_whitespace
|
|
]
|
|
)
|
|
|
|
defcombinatorp(:document, repeat(base))
|
|
defcombinatorp(:liquid_tag_contents, repeat(liquid_tags_parser))
|
|
|
|
defparsec(:parse, parsec(:document) |> eos())
|
|
end
|