Packages
readme_tester
0.1.0
A library for testing Elixir code blocks in markdown files. Ensures your documentation stays in sync with your actual code.
Current section
Files
Jump to
Current section
Files
lib/readme_tester.ex
defmodule ReadmeTester do
@moduledoc """
A library for testing Elixir code blocks in markdown files.
ReadmeTester extracts Elixir code blocks from your markdown documentation
and runs them as tests, ensuring your documentation stays in sync with
your actual code.
## Installation
Add `readme_tester` to your list of dependencies in `mix.exs`:
def deps do
[
{:readme_tester, "~> 0.1.0", only: :test}
]
end
## Quick Start
Create a test file in `test/readme_test.exs`:
defmodule MyApp.ReadmeTest do
use ReadmeTester.Case,
files: ["README.md"],
base_path: File.cwd!()
end
Run with `mix test`.
## Configuration Options
- `:files` - List of markdown files or glob patterns (required)
- `:base_path` - Base path for resolving file paths (default: current directory)
- `:setup` - Setup code to run before each block (default: "")
- `:skip_patterns` - List of regex patterns; blocks matching any will be skipped
## Markdown Annotations
Control test behavior with HTML comments in your markdown:
<!-- readme_tester: skip -->
```elixir
# This code block will be skipped
```
<!-- readme_tester: setup -->
```elixir
# This provides setup for subsequent blocks
alias MyApp.SomeModule
```
## Example
defmodule MyApp.DocsTest do
use ReadmeTester.Case,
files: ["README.md", "docs/**/*.md"],
base_path: File.cwd!(),
skip_patterns: [~r/def deps do/],
setup: "alias MyApp.{User, Order}"
end
"""
alias ReadmeTester.{Parser, Executor}
@doc """
Parses a markdown file and returns all Elixir code blocks.
## Examples
iex> {:ok, blocks} = ReadmeTester.parse_file("README.md")
iex> length(blocks) > 0
true
"""
defdelegate parse_file(path), to: Parser
@doc """
Parses markdown content string and returns all Elixir code blocks.
"""
defdelegate parse_content(content), to: Parser
@doc """
Executes a single code block.
## Options
- `:binding` - variable bindings to use
- `:setup` - setup code to run before the block
"""
defdelegate execute(block, opts \\ []), to: Executor
@doc """
Executes multiple code blocks in sequence.
"""
defdelegate execute_all(blocks, opts \\ []), to: Executor
@doc """
Tests all code blocks in the given files.
Returns a summary of results.
## Options
- `:base_path` - Base path for resolving file paths
- `:setup` - Setup code to run before each block
- `:skip_patterns` - Patterns for blocks to skip
"""
@spec test_files([Path.t()], keyword()) :: %{
total: non_neg_integer(),
passed: non_neg_integer(),
failed: non_neg_integer(),
skipped: non_neg_integer(),
failures: [{Path.t(), non_neg_integer(), term()}]
}
def test_files(files, opts \\ []) do
base_path = Keyword.get(opts, :base_path, File.cwd!())
setup = Keyword.get(opts, :setup, "")
skip_patterns = Keyword.get(opts, :skip_patterns, [])
all_files =
files
|> Enum.flat_map(fn pattern ->
full_pattern = Path.join(base_path, pattern)
Path.wildcard(full_pattern)
end)
|> Enum.uniq()
initial = %{total: 0, passed: 0, failed: 0, skipped: 0, failures: []}
Enum.reduce(all_files, initial, fn file, acc ->
case Parser.parse_file(file) do
{:ok, blocks} ->
test_blocks(blocks, file, setup, skip_patterns, acc)
{:error, _reason} ->
acc
end
end)
end
defp test_blocks(blocks, file, setup, skip_patterns, acc) do
{final_acc, _binding} =
Enum.reduce(blocks, {acc, []}, fn block, {acc, binding} ->
acc = %{acc | total: acc.total + 1}
should_skip =
:skip in block.annotations or
Enum.any?(skip_patterns, fn pattern -> Regex.match?(pattern, block.code) end)
if should_skip do
{%{acc | skipped: acc.skipped + 1}, binding}
else
result = Executor.execute(block, setup: setup, binding: binding)
case result do
{:ok, _} ->
{%{acc | passed: acc.passed + 1}, binding}
{:ok, _, new_binding} ->
# Block with :share annotation - pass bindings forward
{%{acc | passed: acc.passed + 1}, new_binding}
{:skipped, _} ->
{%{acc | skipped: acc.skipped + 1}, binding}
{:error, type, error} ->
{%{
acc
| failed: acc.failed + 1,
failures: [{file, block.line, {type, error}} | acc.failures]
}, binding}
{:error, type, error, _stacktrace} ->
{%{
acc
| failed: acc.failed + 1,
failures: [{file, block.line, {type, error}} | acc.failures]
}, binding}
end
end
end)
final_acc
end
end