Files
bDS2/lib/bds/rendering/liquid_parser.ex

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