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/parser.ex
defmodule ReadmeTester.Parser do
@moduledoc """
Parses markdown files to extract Elixir code blocks.
"""
@type code_block :: %{
code: String.t(),
line: pos_integer(),
language: String.t(),
annotations: [atom()]
}
@doc """
Extracts all Elixir code blocks from a markdown file.
Returns a list of maps containing:
- `:code` - the code content
- `:line` - the starting line number
- `:language` - "elixir" or "iex"
- `:annotations` - list of annotations from HTML comments (e.g., skip, setup)
"""
@spec parse_file(Path.t()) :: {:ok, [code_block()]} | {:error, term()}
def parse_file(path) do
case File.read(path) do
{:ok, content} -> {:ok, parse_content(content)}
{:error, reason} -> {:error, reason}
end
end
@doc """
Parses markdown content and extracts Elixir code blocks.
"""
@spec parse_content(String.t()) :: [code_block()]
def parse_content(content) do
lines = String.split(content, "\n")
lines
|> Enum.with_index(1)
|> extract_blocks([], [])
|> Enum.reverse()
end
# acc = accumulated blocks (maps)
# recent_lines = recent non-code lines for annotation detection
defp extract_blocks([], acc, _recent_lines), do: acc
defp extract_blocks([{line, line_num} | rest], acc, recent_lines) do
case parse_fence_start(line) do
{:ok, language} when language in ["elixir", "iex"] ->
annotations = extract_preceding_annotations(recent_lines, [])
{code_lines, remaining} = collect_code_block(rest, [])
code = Enum.join(Enum.reverse(code_lines), "\n")
block = %{
code: code,
line: line_num,
language: language,
annotations: annotations
}
# Reset recent_lines after consuming a code block
extract_blocks(remaining, [block | acc], [])
_ ->
# Keep track of recent lines for annotation detection
# Limit to last 5 lines for efficiency
new_recent = [{line, line_num} | Enum.take(recent_lines, 4)]
extract_blocks(rest, acc, new_recent)
end
end
defp parse_fence_start(line) do
trimmed = String.trim_leading(line)
# Calculate indentation - if indented by 4+ spaces, it's inside a markdown code block
indent = String.length(line) - String.length(trimmed)
cond do
# Skip if indented (inside markdown indented code block)
indent >= 4 -> :no_match
String.starts_with?(trimmed, "```elixir") -> {:ok, "elixir"}
String.starts_with?(trimmed, "```iex") -> {:ok, "iex"}
true -> :no_match
end
end
defp collect_code_block([], acc), do: {acc, []}
defp collect_code_block([{line, _line_num} | rest], acc) do
if String.trim_leading(line) |> String.starts_with?("```") do
{acc, rest}
else
collect_code_block(rest, [line | acc])
end
end
# Look back through recent lines for HTML comment annotations
# Format: <!-- readme_tester: skip --> or <!-- readme_tester: setup -->
defp extract_preceding_annotations(recent_lines, annotations) do
case recent_lines do
[{line, _} | rest] ->
case parse_annotation(line) do
{:ok, annotation} ->
extract_preceding_annotations(rest, [annotation | annotations])
:no_match ->
# Stop if we hit a non-empty, non-annotation line
if String.trim(line) == "" do
extract_preceding_annotations(rest, annotations)
else
annotations
end
end
[] ->
annotations
end
end
defp parse_annotation(line) do
trimmed = String.trim(line)
case Regex.run(~r/<!--\s*readme_tester:\s*(\w+)\s*-->/, trimmed) do
[_, annotation] -> {:ok, String.to_atom(annotation)}
nil -> :no_match
end
end
end