Skip to content

Commit

Permalink
draft version
Browse files Browse the repository at this point in the history
  • Loading branch information
m1dnight committed Dec 9, 2024
1 parent 4b2034e commit de2ceb2
Show file tree
Hide file tree
Showing 18 changed files with 621 additions and 97 deletions.
49 changes: 45 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# ExExample

**TODO: Add description**
`ExExample` aims to provide an example-driven test framework for Elixir applications.

As opposed to regular unit tests, examples are supposed to be executed from within the REPL.

Examples serve both as a unit test, but also as a tool to discover, learn, and interact with a live
system such as Elixir applications.

## Installation

Expand All @@ -15,7 +20,43 @@ def deps do
end
```

Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/ex_example>.
## Your First Example

To get started, create a new module in the `lib/` folder of your Elixir application and add an example.

```elixir
defmodule MyExamples do
use ExExample
defexample read_data() do
1..1000 |> Enum.shuffle() |> Enum.take(10)
end
end
```

In a running REPL with your application loaded, you can execute this example using `MyExamples.read_data()`.
The example will be executed once, and the cached result will be returned the next time around.

## Caching

In a REPL session it's not uncommon to recompile your code (e.g., using `recompile()`). This changes
the semantics of your examples.

To avoid working with stale outputs, `ExExample` only returns the cached version of your example
if the code it depends on, or the example itself, have not been changed.

When the code changes, the example is executed again.

## Tests

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.

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

```elixir
defmodule MyExamplesTest do
use ExExample.Test, for: MyExamples
end
```
101 changes: 92 additions & 9 deletions lib/ex_example.ex
Original file line number Diff line number Diff line change
@@ -1,17 +1,100 @@
defmodule ExExample do
@moduledoc """
I am the ExExample Application Module
Documentation for `ExExample`.
"""
alias ExExample.Analyze
alias ExExample.Executor

I startup the ExExample system as an OTP application. Moreover Ι
provide all the API necessary for the user of the system. I contain
all public functionality
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)

### Public API
"""
# module attribute that holds all the examples
Module.register_attribute(__MODULE__, :example_dependencies, accumulate: true)
Module.register_attribute(__MODULE__, :examples, accumulate: true)

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

defmacro example({example_name, context, args} = name, do: body) do
called_functions = Analyze.extract_function_calls(body, __CALLER__)

# example_name is the name of the function that is being tested
# e.g., `example_name`

# hidden_func_name is the name of the hidden function that is being tested
# this will contain the actual body of the example
# __example_name__
hidden_example_name = String.to_atom("__#{example_name}__")

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

use Application
@example_dependencies {unquote(example_name), unquote(called_functions)}
@examples unquote(example_name)
def unquote(name) do
example_dependencies = __example_dependencies__(unquote(example_name))

def start(_type, args \\ []) do
ExExample.Supervisor.start_link(args)
Executor.maybe_run_example(__MODULE__, unquote(example_name), example_dependencies)
end
end
end
end
171 changes: 171 additions & 0 deletions lib/ex_example/analyzer/analyze.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
defmodule ExExample.Analyze do
@moduledoc """
I contain functionality to analyze ASTs.
I have functionality to extract modules on which an AST depends,
function calls it makes, and definitions from a module AST.
"""
require Logger

defmodule State do
@moduledoc """
I implement the state for analyzing an AST.
"""
defstruct called_functions: [], env: nil, functions: []

def put_call(state, mod, arg) do
%{state | called_functions: [{mod, arg} | state.called_functions]}
end

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

def extract_function_calls(ast, env) do
state = %State{env: env}
# IO.inspect(env)
{_, state} = Macro.prewalk(ast, state, &extract_function_calls_logged/2)
state.called_functions
end

defp extract_function_calls_logged(ast, state) do
# IO.puts("------------------------------------------- ")
# IO.inspect(ast)

extract_function_call(ast, state)
end

# qualified function call
# e.g., Foo.bar()
defp extract_function_call(
{{:., _, [{:__aliases__, _, aliases}, func_name]}, _, args} = ast,
state
) do
case Macro.Env.expand_alias(state.env, [], aliases) do
:error ->
arg_count = Enum.count(args)
module = Module.concat(aliases)
state = State.put_call(state, {module, func_name}, arg_count)
{ast, state}

{:alias, resolved} ->
arg_count = Enum.count(args)
state = State.put_call(state, {resolved, func_name}, arg_count)
{ast, state}
end
end

# variable in binding
# e.g. `x` in `x = 1`
defp extract_function_call({_func, _, nil} = ast, state) do
{ast, state}
end

@special_forms Kernel.SpecialForms.__info__(:macros)
defp extract_function_call({func, _, args} = ast, state) do
arg_count = Enum.count(args)

state =
case Macro.Env.lookup_import(state.env, {func, arg_count}) do
# imported call
[{:function, module}] ->
State.put_call(state, {module, func}, arg_count)

[{:macro, _module}] ->
state

# local def
[] ->
if {func, arg_count} in @special_forms or func in [:__block__, :&, :__aliases__] do
state
else
State.put_call(state, {state.env.module, func}, arg_count)
end
end

{ast, state}
end

defp extract_function_call(ast, state) do
{ast, state}
end

# ----------------------------------------------------------------------------
# Exctract function definitions from module

@doc """
Given the path of a source file, I extract the definitions of the functions.
"""
@spec extract_defs(String.t(), Macro.Env.t()) :: [{atom(), non_neg_integer()}]
def extract_defs(file, env) do
source = File.read!(file)
{:ok, ast} = Code.string_to_quoted(source)
extract_defs_from_source(ast, env)
end

defp extract_defs_from_source(ast, env) do
# create the initial state
state = %State{env: env}

# walk the ast and extract the function definitions
{_, state} = Macro.prewalk(ast, state, &extract_def_logged/2)

state.functions
end

defp extract_def_logged(ast, state) do
# IO.puts("------------------------------------------- ")
# IO.inspect(ast)

extract_def(ast, state)
end

defp extract_def({:example, _, [{fun, _, args}, _body]} = ast, state) do
state =
args
|> count_args()
|> Enum.reduce(state, fn i, state ->
State.put_def(state, fun, i)
end)

{ast, state}
end

defp extract_def(ast, state) do
{ast, state}
end

# @doc """
# I count the arguments in an argument list.
# I return the number of required arguments followed by the number of optional arguments.
# """
@spec count_args([any()]) :: any()
defp count_args(args) do
{req, opt} =
args
|> Enum.reduce({0, 0}, fn
{:\\, _, [{_arg, _, _}, _]}, {req, opt} ->
{req, opt + 1}

_, {req, opt} ->
{req + 1, opt}
end)

req..(req + opt)
end
end
13 changes: 13 additions & 0 deletions lib/ex_example/application.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule ExExample.Application do
@moduledoc false

use Application

@impl true
def start(_type, _args) do
children = [{Cachex, [ExExample.Cache]}]

opts = [strategy: :one_for_one, name: ExExample.Supervisor]
Supervisor.start_link(children, opts)
end
end
12 changes: 0 additions & 12 deletions lib/ex_example/behaviour.ex

This file was deleted.

Empty file added lib/ex_example/cache.ex
Empty file.
Loading

0 comments on commit de2ceb2

Please sign in to comment.