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: ```elixir # This code block will be skipped ``` ```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