Skip to content

Commit

Permalink
behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
m1dnight committed Dec 13, 2024
1 parent f500d84 commit 7a55fab
Show file tree
Hide file tree
Showing 14 changed files with 437 additions and 171 deletions.
2 changes: 1 addition & 1 deletion .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
# {Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ if the code it depends on, or the example itself, have not been changed.

When the code changes, the example is executed again.

## Tests
## Tests

The examples are created to work with the code base, but they can also serve as a unit test.
The examples are created to work with the code base, but they can also serve as a unit test.

To let ExUnit use the examples in your codebase as tests, add a test file in the `test/` folder, and
import the `ExExample.Test` module.
import the `ExExample.Test` module.

To run the examples from above, add a file `ny_examples_test.exs` to your `test/` folder and include the following.

Expand Down
179 changes: 109 additions & 70 deletions lib/ex_example.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,79 +3,120 @@ defmodule ExExample do
Documentation for `ExExample`.
"""
alias ExExample.Analyze
alias ExExample.Cache
alias ExExample.Executor

############################################################
# Types #
############################################################

@typedoc """
A dependency is a function that will be called by an example.
The format of a dependency is `{{module, function}, arity}`
"""
@type dependency :: {{atom(), atom()}, non_neg_integer()}

@typedoc """
"""
@type example :: {atom(), list(dependency)}

############################################################
# Helpers #
############################################################

@doc """
I return the hidden name of an example.
The hidden name is the example body without modification.
"""
@spec hidden_name({atom(), atom()}) :: {atom(), atom()}
def hidden_name({module, func}) do
{module, String.to_atom("__#{func}__")}
end

@doc """
I determine if a module/function pair is an example or not.
A function is an example if it is defined in a module that has the `__examples__/0` function
implemented, and when the `__examples__()` output lists that function name as being an example.
"""
@spec example?(dependency()) :: boolean()
def example?({{module, func}, _arity}) do
example_module?(module) and Keyword.has_key?(module.__examples__(), func)
end

@doc """
I return true if the given module contains examples.
"""
@spec example_module?(atom()) :: boolean
def example_module?(module) do
{:__examples__, 0} in module.__info__(:functions)
end

@doc """
I return a list of all dependencies for this example.
Note: this does includes other called modules too (e.g., Enum).
"""
@spec all_dependencies({atom(), atom()}) :: [dependency()]
def all_dependencies({module, func}) do
module.__examples__()
|> Keyword.get(func, [])
end

@doc """
I return a list of example dependencies for this example.
Note: this does not include other called modules.
"""
@spec example_dependencies({atom(), atom()}) :: [dependency()]
def example_dependencies({module, func}) do
all_dependencies({module, func})
|> Enum.filter(&example?/1)
end

@doc """
I return a list of examples in the order they should be
executed in.
I do this by topologically sorting their execution order.
"""
@spec execution_order(atom()) :: [{atom(), atom()}]
def execution_order(module) do
module.__examples__()
|> Enum.reduce(Graph.new(), fn
{function, []}, g ->
Graph.add_vertex(g, {__MODULE__, function})

{function, dependencies}, g ->
dependencies
# filter out all non-example dependencies
|> Enum.filter(&example?/1)
|> Enum.reduce(g, fn {{module, func}, _arity}, g ->
Graph.add_edge(g, {module, func}, {module, function})
end)
end)
|> Graph.topsort()
end

############################################################
# Macros #
############################################################

defmacro __using__(_options) do
quote do
import unquote(__MODULE__)

@behaviour ExExample.Behaviour

# module attribute that holds all the examples
Module.register_attribute(__MODULE__, :example_dependencies, accumulate: true)
Module.register_attribute(__MODULE__, :examples, accumulate: true)
Module.register_attribute(__MODULE__, :copies, accumulate: true)
Module.register_attribute(__MODULE__, :copy, accumulate: false)

@before_compile unquote(__MODULE__)
end
end

defmacro __before_compile__(_env) do
quote do
@doc """
I return a list of all the dependencies for a given example,
or the list of all dependencies if no argument is given.
"""
def __example_dependencies__, do: @example_dependencies

def __example_dependencies__(dependee) do
@example_dependencies
|> Enum.find({nil, []}, fn {name, _} -> name == dependee end)
|> elem(1)
end

@doc """
I reutrn all the examples in this module.
"""
def __examples__ do
@examples
end

@doc """
I run all the examples in this module.
"""
def __run_examples__ do
__sorted__()
|> Enum.each(fn {module, name} ->
apply(module, name, [])
end)
end

@doc """
I return a topologically sorted list of examples.
This list is the order in which the examples should be run.
"""
@spec __sorted__() :: list({atom(), atom()})
def __sorted__ do
__example_dependencies__()
|> Enum.reduce(Graph.new(), fn
{example, []}, g ->
Graph.add_vertex(g, {__MODULE__, example})

{example, dependencies}, g ->
dependencies
# filter out all non-example dependencies
|> Enum.filter(&Executor.example?/1)
|> Enum.reduce(g, fn {{module, func}, _arity}, g ->
Graph.add_edge(g, {module, func}, {__MODULE__, example})
end)
end)
|> Graph.topsort()
end

def __example_copy__(example_name) do
@copies
|> Keyword.get(example_name, nil)
end
@spec __examples__ :: [ExExample.example()]
def __examples__, do: @examples
end
end

Expand All @@ -91,24 +132,22 @@ defmodule ExExample do
hidden_example_name = String.to_atom("__#{example_name}__")

quote do
# fetch the attribute value, and then clear it for the next examples.
example_copy_tag = Module.get_attribute(unquote(__CALLER__.module), :copy)
Module.delete_attribute(unquote(__CALLER__.module), :copy)

def unquote({hidden_example_name, context, args}) do
unquote(body)
end

@copies {unquote(example_name), {unquote(__CALLER__.module), example_copy_tag}}
@example_dependencies {unquote(example_name), unquote(called_functions)}
@examples unquote(example_name)
@examples {unquote(example_name), unquote(called_functions)}
def unquote(name) do
example_dependencies = __example_dependencies__(unquote(example_name))
example_copy = __example_copy__(unquote(example_name))
case Executor.attempt_example({__MODULE__, unquote(example_name)}, []) do
%{result: %Cache.Result{success: :success} = result} ->
result.result

%{result: %Cache.Result{success: :failed} = result} ->
raise result.result

Executor.maybe_run_example(__MODULE__, unquote(example_name), example_dependencies,
copy: example_copy
)
%{result: %Cache.Result{success: :skipped} = result} ->
:skipped
end
end
end
end
Expand Down
19 changes: 8 additions & 11 deletions lib/ex_example/analyzer/analyze.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,21 @@ defmodule ExExample.Analyze do
"""
defstruct called_functions: [], env: nil, functions: []

@spec put_call(map(), {atom(), atom()}, non_neg_integer()) :: map()
def put_call(state, mod, arg) do
%{state | called_functions: [{mod, arg} | state.called_functions]}
end

@spec put_def(map(), atom(), non_neg_integer()) :: map()
def put_def(state, func, arity) do
%{state | functions: [{func, arity} | state.functions]}
end
end

# ----------------------------------------------------------------------------
# Compute hash of all modules that the example depends on

def compile_dependency_hash(dependencies) do
dependencies
|> Enum.map(fn {{module, _func}, _arity} ->
module.__info__(:attributes)[:vsn]
end)
|> :erlang.phash2()
end

# ----------------------------------------------------------------------------
# Exctract function calls from ast

@spec extract_function_calls(tuple(), Macro.Env.t()) :: [{{atom(), atom()}, non_neg_integer()}]
def extract_function_calls(ast, env) do
state = %State{env: env}
# IO.inspect(env)
Expand All @@ -52,6 +44,7 @@ defmodule ExExample.Analyze do

# qualified function call
# e.g., Foo.bar()

defp extract_function_call(
{{:., _, [{:__aliases__, _, aliases}, func_name]}, _, args} = ast,
state
Expand All @@ -70,6 +63,10 @@ defmodule ExExample.Analyze do
end
end

defp extract_function_call({{:., _, _args}, _, _} = ast, state) do
{ast, state}
end

# variable in binding
# e.g. `x` in `x = 1`
defp extract_function_call({_func, _, nil} = ast, state) do
Expand Down
12 changes: 12 additions & 0 deletions lib/ex_example/behaviour.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule ExExample.Behaviour do
@moduledoc """
I help determine when Examples ought to be run again or be copied
I do this by defining out a behaviour that is to be used with the
use macro for ExExample
"""

@callback rerun?(any()) :: boolean()
@callback copy(any()) :: any()
end
Empty file removed lib/ex_example/cache.ex
Empty file.
11 changes: 10 additions & 1 deletion lib/ex_example/cache/cache.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ defmodule ExExample.Cache do

@cache_name __MODULE__

@doc """
I clear the entire cache.
"""
@spec clear() :: :ok
def clear do
Cachex.clear!(@cache_name)
:ok
end

@doc """
I store a result in cache for a given key.
"""
Expand All @@ -22,7 +31,7 @@ defmodule ExExample.Cache do
I fetch a previous Result from the cache if it exists.
If it does not exist, I return `{:error, :not_found}`.
"""
@spec get_result(Key.t()) :: {:ok, any()} | {:error, :no_result}
@spec get_result(Key.t()) :: {:ok, Result.t()} | {:error, :no_result}
def get_result(%Key{} = key) do
case Cachex.get(@cache_name, key) do
{:ok, nil} ->
Expand Down
1 change: 1 addition & 0 deletions lib/ex_example/cache/result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ defmodule ExExample.Cache.Result do
field(:key, Key.t())
field(:success, :failed | :success | :skipped)
field(:result, term())
field(:cached, boolean(), default: true)
end
end
Loading

0 comments on commit 7a55fab

Please sign in to comment.