From a6c27354e71504467d29b422ba9e9a6e3b3d6627 Mon Sep 17 00:00:00 2001 From: Niklas Lindgren Date: Fri, 12 Jul 2024 12:15:48 +0300 Subject: [PATCH 1/2] feat(matchers): exclude bundled matchers by default commit 3188526e2fbc918446def040755ef3f4aa3a67c4 Author: Niklas Lindgren Date: Tue Jun 11 15:24:24 2024 +0300 docs(readme): adds new section on including Solid in your project This documents the altered approach to including Solid a project, the changes to importing the default matchers and how to bring your own matchers for types with bundled matchers. commit ddcb0fdd367b6a3e0a7bedb96b856511e7efc74d Author: Niklas Lindgren Date: Tue Jun 11 15:24:05 2024 +0300 test(test helpers): add `use Solid` to test helpers commit 67c60c75a3c6fcb89b946d72a83cbb6664e4fa36 Author: Niklas Lindgren Date: Tue Jun 11 15:21:59 2024 +0300 feat: export a `use Solid` macro for default config usage This is a convenience method, that makes including Solid in your project using the default config easier. This will be the simplest and likeliest upgrade path for most Solid users. commit 2dcd98024a8e5801d3546fe23d1816badbc76984 Author: Niklas Lindgren Date: Tue Jun 11 15:20:28 2024 +0300 test(matchers): add cases for built-in matchers commit ac54b6b818a6166559a67940477d01c2cabff3e3 Author: Niklas Lindgren Date: Tue Jun 11 14:57:33 2024 +0300 feat!(matchers): exclude bundled matchers by default Packages the bundled matchers in a new module `Solid.Matcher.Builtins`, adding a `__using__` macro for including them in your own project. This is a breaking change, but the only way for supporting custom implementations in place of the bundled matchers. commit 0a86872ee2b512ed8dbdda201bf57c1e6c701542 Author: Matt Sutkowski Date: Mon Apr 29 21:14:23 2024 -0700 chore: update docs around render/3 and README --- README.md | 63 +++++++++++++ lib/solid.ex | 52 +++++++++++ lib/solid/matcher.ex | 170 +++++++++++++++++++++++------------- test/solid/matcher_test.exs | 84 +++++++++++++----- 4 files changed, 288 insertions(+), 81 deletions(-) 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..73a3adb 100644 --- a/lib/solid/matcher.ex +++ b/lib/solid/matcher.ex @@ -4,80 +4,130 @@ defprotocol Solid.Matcher do def match(_, _) end -defimpl Solid.Matcher, for: Any do - def match(data, []), do: {:ok, data} +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. - def match(_, _), do: {:error, :not_found} -end + 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. -defimpl Solid.Matcher, for: List do - def match(data, []), do: {:ok, data} + The full list of available matchers is `:any`, `:atom`, `:list`, `:map`, `:string` - 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)} + Examples: - 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 + # include all built-in matchers + use Solid.Matcher.Builtins - def match(_data, _) do - {:error, :not_found} - end -end - -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 + # selectively include only a subset + use Solid.Matcher.Builtins, only: [:any, :list] - 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] + + @type matcher :: :any | :atom | :list | :map | :string + @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 + 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 -defimpl Solid.Matcher, for: BitString do - def match(current, []), do: {:ok, current} + if :string in unquote(included) do + defimpl Solid.Matcher, for: [BitString, String] do + def match(current, []), do: {:ok, current} - def match(data, ["size"]) do - {:ok, String.length(data)} - end + def match(data, ["size"]) do + {:ok, String.length(data)} + end - def match(_data, [i | _]) when is_integer(i) do - {:error, :not_found} - end + def match(_data, [i | _]) when is_integer(i) do + {:error, :not_found} + end - def match(_data, [i | _]) when is_binary(i) do - {:error, :not_found} - end -end + def match(_data, [i | _]) when is_binary(i) do + {:error, :not_found} + 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 -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} + if :any in unquote(included) do + defimpl Solid.Matcher, for: Any do + def match(data, []), do: {:ok, data} - @doc """ - Matches all remaining cases - """ - def match(_current, [key]) when is_binary(key), do: {:error, :not_found} + def match(_, _), do: {:error, :not_found} + end + end + end + end end defimpl Solid.Matcher, for: Tuple do 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 From c7b8d4d18b6b088938ddfa0d697601c8f67fcdb7 Mon Sep 17 00:00:00 2001 From: Niklas Lindgren Date: Tue, 18 Mar 2025 15:08:35 +0200 Subject: [PATCH 2/2] feat(matchers): include tuple matcher from upstream --- lib/solid/matcher.ex | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/lib/solid/matcher.ex b/lib/solid/matcher.ex index 73a3adb..9ce9f3e 100644 --- a/lib/solid/matcher.ex +++ b/lib/solid/matcher.ex @@ -12,7 +12,7 @@ defmodule Solid.Matcher.Builtins do 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. - The full list of available matchers is `:any`, `:atom`, `:list`, `:map`, `:string` + The full list of available matchers is `:any`, `:atom`, `:list`, `:map`, `:string`, `:tuple` Examples: @@ -26,9 +26,9 @@ defmodule Solid.Matcher.Builtins do use Solid.Matcher.Builtins, except: [:map, :atom] """ - @all_matchers [:any, :atom, :string, :list, :map] + @all_matchers [:any, :atom, :string, :list, :map, :tuple] - @type matcher :: :any | :atom | :list | :map | :string + @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() @@ -106,6 +106,25 @@ defmodule Solid.Matcher.Builtins do 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} @@ -129,20 +148,3 @@ defmodule Solid.Matcher.Builtins do end end end - -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