Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
elixir 1.15.4
erlang 25.3.2.5
ruby 3.0.0
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,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:
Expand Down
52 changes: 52 additions & 0 deletions lib/solid.ex
Original file line number Diff line number Diff line change
@@ -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 """
Main module to interact with Solid
"""
Expand Down
201 changes: 124 additions & 77 deletions lib/solid/matcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,91 +4,138 @@ 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

defimpl Solid.Matcher, for: List do
def match(data, []), do: {:ok, data}

def match(data, ["size"]) do
{:ok, Enum.count(data)}
end

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
end

defimpl Solid.Matcher, for: Map do
def match(data, []) do
{:ok, data}
end

def match(data, ["size"]) do
{:ok, Map.get(data, "size", Enum.count(data))}
end

def match(data, [key | []]) do
case Map.fetch(data, key) do
{:ok, value} -> {:ok, value}
_ -> {:error, :not_found}
end
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.

def match(data, [key | keys]) do
case Map.fetch(data, key) do
{:ok, value} -> @protocol.match(value, keys)
_ -> {:error, :not_found}
end
end
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: BitString do
def match(current, []), do: {:ok, current}
The full list of available matchers is `:any`, `:atom`, `:list`, `:map`, `:string`, `:tuple`

def match(data, ["size"]) do
{:ok, String.length(data)}
end
Examples:

def match(_data, [i | _]) when is_integer(i) do
{:error, :not_found}
end
# include all built-in matchers
use Solid.Matcher.Builtins

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}
# selectively include only a subset
use Solid.Matcher.Builtins, only: [:any, :list]

@doc """
Matches all remaining cases
# selectively exclude a subset
use Solid.Matcher.Builtins, except: [:map, :atom]
"""
def match(_current, [key]) when is_binary(key), do: {:error, :not_found}
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}
@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, ["size" | tail]), do: data |> Enum.count() |> @protocol.match(tail)

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
end
end

if :map in unquote(included) do
defimpl Solid.Matcher, for: Map do
def match(data, []) do
{:ok, data}
end

def match(data, ["size" | tail]),
do: data |> Map.get("size", Enum.count(data)) |> @protocol.match(tail)

def match(data, [head | []]) do
case Map.fetch(data, head) do
{:ok, value} -> {:ok, value}
_ -> {:error, :not_found}
end
end

def match(data, [head | tail]) do
case Map.fetch(data, head) do
{:ok, value} -> @protocol.match(value, tail)
_ -> {:error, :not_found}
end
end
end
end

if :string in unquote(included) do
defimpl Solid.Matcher, for: [BitString, String] do
def match(current, []), do: {:ok, current}

def match(data, ["size" | tail]), do: data |> String.length() |> @protocol.match(tail)

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
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

if :any in unquote(included) do
defimpl Solid.Matcher, for: Any do
def match(data, []), do: {:ok, data}

def match(d, s), do: {:error, :not_found}
end
end
end
end
end
Loading