diff --git a/README.md b/README.md index 86dba3f..18124b2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,69 @@ def deps do end ``` +## Including Solid in your project + +Solid comes with bundled matchers for the basic Elixir data types. To enable you to use your own +implementations instead, the bundled matchers aren't included by default. This is necessary, +because matchers implement a protocol, which means they can only be defined once for each type. + +If you're happy to use the bundled matchers (the defaults), all you need to to do is to include a +call to `use Solid` or `use Solid.Matcher.Builtins` in your application code. Which one you choose +is up to you and effectually makes no difference. If you, on the other hand, want to replace some +of the defaults in Solid, there is some nuance. + +By default, calling `use Solid` includes the bundled matchers and creates local methods for +`render/3`, `render!/3`, `parse/2` and `parse!/2` in your wrapper module. To select which +delegates are created, use the `:delegates` argument. The empty list or `false` omits all +delegates. + +If you want to wrap Solid – it can be a convenient way to wrap calls to public methods for +e.g. always including specific options – and _also_ want to bring your own matchers, pass +the `:nomatchers` argument with any value. + +```elixir +defmodule MyProject.Solid do + # `use Solid` by default creates delegates to `render/3`, `render!/3`, `parse/2` and + # `parse!/2` in your wrapper module. That means you can call `MyProject.Solid.render/3` etc, + # instead of using Solid methods directly. Using wrapper methods in your module can be a + # convenient way to e.g. including custom options. Always calling the public methods on + # your wrapper module makes it convenient to change from delegate to wrapped method later on. + # + # To pick which local methods are created, use the `delegates` argument. + # To not import the bundled matchers, use the `nomatchers` argument. + + # wrap Solid, using all defaults + use Solid + + # wrap Solid, but exclude the bundled matchers + use Solid, nomatchers: true + + # wrap Solid, but exclude the delegated render methods + use Solid, delegates: [:parse, :parse!] +end +``` + +The `use Solid.Matcher.Builtins` macro has options for cherry-picking the bundled matchers, +refer to the module documentation for more details. Call `use Solid.Matcher.Builtins` in your +module alongside your custom matchers and you're good to go. + +```elixir +defmodule MyProject.Solid.Matchers do + # `use Solid.Matcher.Builtins` includes the bundled matchers for basic Elixir data types. + # If you want to bring your own custom implementation, use the `except` and `only` + # arguments for cherry-picking and add your own implementations to this module. + + # use all the bundled matchers + use Solid.Matcher.Builtins + + # exclude the matcher for the Map type + use Solid.Matcher.Builtins, except: [:map] + + # only include the matchers for the Any and Atom types + use Solid.Matcher.Builtins, only: [:any, :atom] +end +``` + ## Custom tags To implement a new tag you need to create a new module that implements the `Tag` behaviour. It must implement a `parse/3` function that returns a struct that implements `Solid.Renderable`. Here is a simple example: diff --git a/lib/solid.ex b/lib/solid.ex index 7abf301..ffd1e62 100644 --- a/lib/solid.ex +++ b/lib/solid.ex @@ -1,4 +1,56 @@ defmodule Solid do + @doc """ + It sets up Solid with built-in matchers for basic types and creates local delegates to `render`, + `render!`, `parse`, and `parse!` in your wrapper module. + + Use this macro to include Solid in your project with a default configuration. + + If you're not using the default configuration and wish to customise one or several of the basic + matchers, refer to the documentation in the `Solid.Matcher.Builtins` module. + + Use the `:delegates` option to include only a subset of the delegates in your wrapper module. To + exclude all delegates, pass `false` or the empty list. + + Use the `:nomatchers` option to exclude the bundled matchers. + """ + @all_delegates [:render, :render!, :parse, :parse!] + @type delegates :: :render | :render! | :parse | :parse! + @type option :: {:delegates, list(delegates()) | boolean()} | {:nomatchers, any()} + @type options :: list(option()) + @spec __using__(options()) :: Macro.t() + defmacro __using__(options) do + delegates = + case Keyword.get(options, :delegates, @all_delegates) do + true -> @all_delegates + [_ | _] = v -> v + _ -> [] + end + + matchers = not Keyword.has_key?(options, :nomatchers) + + quote do + if unquote(matchers) do + use Solid.Matcher.Builtins + end + + if :render in unquote(delegates) do + defdelegate render, to: Solid + end + + if :render! in unquote(delegates) do + defdelegate render!, to: Solid + end + + if :parse in unquote(delegates) do + defdelegate parse, to: Solid + end + + if :parse! in unquote(delegates) do + defdelegate parse!, to: Solid + end + end + end + @moduledoc """ Solid is an implementation in Elixir of the [Liquid](https://shopify.github.io/liquid/) template language with strict parsing. diff --git a/lib/solid/matcher.ex b/lib/solid/matcher.ex index 844a883..9ce9f3e 100644 --- a/lib/solid/matcher.ex +++ b/lib/solid/matcher.ex @@ -4,95 +4,147 @@ defprotocol Solid.Matcher do def match(_, _) end -defimpl Solid.Matcher, for: Any do - def match(data, []), do: {:ok, data} - - def match(_, _), do: {:error, :not_found} -end +defmodule Solid.Matcher.Builtins do + @doc """ + Solid comes with built-in matchers for the Atom, Map, List, and String/BitString types, as well + as a fallback matcher for the Any type. -defimpl Solid.Matcher, for: List do - def match(data, []), do: {:ok, data} + The using macro supports options to selectively include (`:only`) and exclude (`:except`) + individual matchers, should you wish to replace all or a subset with a custom matcher. - def match(data, ["first"]), do: {:ok, Enum.at(data, 0)} - def match(data, ["last"]), do: {:ok, Enum.at(data, -1)} - def match(data, ["size"]), do: {:ok, Enum.count(data)} + The full list of available matchers is `:any`, `:atom`, `:list`, `:map`, `:string`, `:tuple` - def match(data, [key | keys]) when is_integer(key) do - case Enum.fetch(data, key) do - {:ok, value} -> @protocol.match(value, keys) - _ -> {:error, :not_found} - end - end + Examples: - def match(_data, _) do - {:error, :not_found} - end -end + # include all built-in matchers + use Solid.Matcher.Builtins -defimpl Solid.Matcher, for: Map do - def match(data, []) do - {:ok, data} - end + # selectively include only a subset + use Solid.Matcher.Builtins, only: [:any, :list] - # Maps are not ordered so these are here just for consistency with the Liquid implementation - # as we must return something - - def match(data, [key | keys]) do - case Map.fetch(data, key) do - {:ok, value} -> - @protocol.match(value, keys) + # selectively exclude a subset + use Solid.Matcher.Builtins, except: [:map, :atom] + """ - _ -> - # Check if the key is a special case - case key do - "first" -> @protocol.match(Enum.at(data, 0), keys) - "size" -> @protocol.match(map_size(data), keys) - _ -> {:error, :not_found} + @all_matchers [:any, :atom, :string, :list, :map, :tuple] + + @type matcher :: :any | :atom | :list | :map | :string | :tuple + @type option :: {:only, list(matcher())} | {:except, list(matcher())} + @type options :: list(option()) + @spec __using__(options()) :: Macro.t() + defmacro __using__(opts) do + excluded = Keyword.get(opts, :except, []) + + included = + opts + |> Keyword.get(:only, @all_matchers) + |> Enum.reject(fn m -> m in excluded end) + + quote do + if :list in unquote(included) do + defimpl Solid.Matcher, for: List do + def match(data, []), do: {:ok, data} + + def match(data, ["first"]), do: {:ok, Enum.at(data, 0)} + def match(data, ["last"]), do: {:ok, Enum.at(data, -1)} + def match(data, ["size"]), do: {:ok, Enum.count(data)} + + def match(data, [key | keys]) when is_integer(key) do + case Enum.fetch(data, key) do + {:ok, value} -> @protocol.match(value, keys) + _ -> {:error, :not_found} + end + end + + def match(_data, _) do + {:error, :not_found} + end end - end - end -end - -defimpl Solid.Matcher, for: BitString do - def match(current, []), do: {:ok, current} - - def match(data, ["size"]) do - {:ok, String.length(data)} - end + end + + if :map in unquote(included) do + defimpl Solid.Matcher, for: Map do + def match(data, []) do + {:ok, data} + end + + # Maps are not ordered so these are here just for consistency with the Liquid implementation + # as we must return something + + def match(data, [key | keys]) do + case Map.fetch(data, key) do + {:ok, value} -> + @protocol.match(value, keys) + + _ -> + # Check if the key is a special case + case key do + "first" -> @protocol.match(Enum.at(data, 0), keys) + "size" -> @protocol.match(map_size(data), keys) + _ -> {:error, :not_found} + end + end + end + end + end - def match(_data, [i | _]) when is_integer(i) do - {:error, :not_found} - end + if :string in unquote(included) do + defimpl Solid.Matcher, for: [BitString, String] do + def match(current, []), do: {:ok, current} - def match(_data, [i | _]) when is_binary(i) do - {:error, :not_found} - end -end - -defimpl Solid.Matcher, for: Atom do - def match(current, []) when is_nil(current), do: {:ok, nil} - def match(data, []), do: {:ok, data} - def match(nil, _), do: {:error, :not_found} + def match(data, ["size"]) do + {:ok, String.length(data)} + end - @doc """ - Matches all remaining cases - """ - def match(_current, [key]) when is_binary(key), do: {:error, :not_found} -end + def match(_data, [i | _]) when is_integer(i) do + {:error, :not_found} + end -defimpl Solid.Matcher, for: Tuple do - def match(data, []), do: {:ok, data} + def match(_data, [i | _]) when is_binary(i) do + {:error, :not_found} + end + end + end + + if :tuple in unquote(included) do + defimpl Solid.Matcher, for: Tuple do + def match(data, []), do: {:ok, data} + + def match(data, ["size"]) do + {:ok, tuple_size(data)} + end + + def match(data, [key | keys]) when is_integer(key) do + try do + elem(data, key) + |> @protocol.match(keys) + rescue + ArgumentError -> {:error, :not_found} + end + end + end + end + + if :atom in unquote(included) do + defimpl Solid.Matcher, for: Atom do + def match(current, []) when is_nil(current), do: {:ok, nil} + def match(data, []), do: {:ok, data} + def match(nil, _), do: {:error, :not_found} + + @doc """ + Matches all remaining cases + """ + def match(_current, [key]) when is_binary(key), do: {:error, :not_found} + end + end - def match(data, ["size"]) do - {:ok, tuple_size(data)} - end + if :any in unquote(included) do + defimpl Solid.Matcher, for: Any do + def match(data, []), do: {:ok, data} - def match(data, [key | keys]) when is_integer(key) do - try do - elem(data, key) - |> @protocol.match(keys) - rescue - ArgumentError -> {:error, :not_found} + def match(_, _), do: {:error, :not_found} + end + end end end end diff --git a/test/solid/matcher_test.exs b/test/solid/matcher_test.exs index d524e14..a4502c5 100644 --- a/test/solid/matcher_test.exs +++ b/test/solid/matcher_test.exs @@ -1,38 +1,80 @@ defmodule Solid.MatcherTest do use ExUnit.Case, async: true + import Solid.Helpers - defmodule UserProfile do - defstruct [:full_name] + describe "custom matchers" do + defmodule UserProfile do + defstruct [:full_name] - defimpl Solid.Matcher do - def match(user_profile, ["full_name"]), do: {:ok, user_profile.full_name} + defimpl Solid.Matcher do + def match(user_profile, ["full_name"]), do: {:ok, user_profile.full_name} + end end - end - defmodule User do - defstruct [:email] + defmodule User do + defstruct [:email] + + def load_profile(%User{} = _user) do + # implementation omitted + %UserProfile{full_name: "John Doe"} + end - def load_profile(%User{} = _user) do - # implementation omitted - %UserProfile{full_name: "John Doe"} + defimpl Solid.Matcher do + def match(user, ["email"]), do: {:ok, user.email} + + def match(user, ["profile" | keys]), + do: user |> User.load_profile() |> @protocol.match(keys) + end end - defimpl Solid.Matcher do - def match(user, ["email"]), do: {:ok, user.email} + test "should render protocolized struct correctly" do + template = ~s({{ user.email }}: {{ user.profile.full_name }}) + + context = %{"user" => %User{email: "test@example.com"}} - def match(user, ["profile" | keys]), - do: user |> User.load_profile() |> @protocol.match(keys) + assert "test@example.com: John Doe" == render(template, context) end end - test "should render protocolized struct correctly" do - template = ~s({{ user.email }}: {{ user.profile.full_name }}) + describe "built-in matchers" do + test "for maps" do + template = ~s({{ beep.boop }}) + + context = %{"beep" => %{"boop" => "beep boop!"}} + + assert "beep boop!" == render(template, context) + end + + test "for strings" do + template = + ~s(How long is {{piece_of_string}}? {{piece_of_string.size}}.) - context = %{ - "user" => %User{email: "test@example.com"} - } + context = %{"piece_of_string" => "a piece of string"} - assert "test@example.com: John Doe" == - template |> Solid.parse!() |> Solid.render!(context) |> to_string() + assert "How long is a piece of string? 17." == render(template, context) + end + + test "for lists" do + template = ~s(This eagle is just {{items.size}} {{items[1]}}s in a trenchcoat.) + + context = %{ + "items" => ~w(This parrot has ceased to be.) + } + + assert "This eagle is just 6 parrots in a trenchcoat." == render(template, context) + end + + test "for atoms" do + Enum.each( + [ + {~s({{molecule.atom.particle}}), + %{"molecule" => %{"atom" => %{"particle" => :neutron}}}, "neutron"}, + {~s({{beep.boop}}), %{"beep" => nil}, ""} + ], + fn {template, context, expected} -> + assert expected == render(template, context) + end + ) + end end end