Packages

A fast, configurable markdown formatter for Elixir using Rust's dprint-plugin-markdown

Current section

Files

Jump to
dprint_markdown_formatter lib dprint_markdown_formatter.ex
Raw

lib/dprint_markdown_formatter.ex

defmodule DprintMarkdownFormatter do
@moduledoc """
A fast markdown formatter using dprint-plugin-markdown via Rustler NIF.
This module provides an interface to format markdown text using the
dprint-plugin-markdown formatter implemented in Rust for performance.
## Configuration
Options for dprint can be configured in `mix.exs`:
def project do
[
# ... other config
dprint_markdown_formatter: [
line_width: 100,
text_wrap: :never,
emphasis_kind: :underscores,
# Enable formatting for default attributes (moduledoc, doc, typedoc, shortdoc, deprecated)
format_module_attributes: true,
# Or disable all formatting (default behavior)
# format_module_attributes: nil,
# Or specify custom attributes
# format_module_attributes: [:moduledoc, :doc, :custom_doc, :note]
]
]
end
### Available Options
- `:line_width` - Maximum line width (default: 80)
- `:text_wrap` - Text wrapping behavior: `:always`, `:never`, `:maintain`
(default: `:always`)
- `:emphasis_kind` - Emphasis style: `:asterisks`, `:underscores` (default:
`:asterisks`)
- `:strong_kind` - Strong text style: `:asterisks`, `:underscores` (default:
`:asterisks`)
- `:new_line_kind` - Line ending type: `:auto`, `:lf`, `:crlf` (default:
`:auto`)
- `:unordered_list_kind` - Unordered list style: `:dashes`, `:asterisks`
(default: `:dashes`)
**Note:** Configuration values can be provided as atoms (`:never`) or strings (`"never"`).
Atoms are preferred for consistency with Elixir conventions.
- `:format_module_attributes` - Configure which module attributes to format.
Supports four input types for maximum flexibility:
**`nil` (default):** Skip formatting all module attributes.
**Boolean `true`:** Format common documentation attributes: `:moduledoc`,
`:doc`, `:typedoc`, `:shortdoc`, `:deprecated`
**Boolean `false`:** Skip formatting all module attributes.
**List of atoms:** Format only the specified attributes. Any module attribute
containing string content can be specified. Examples:
# Format only module docs
format_module_attributes: [:moduledoc]
# Format standard docs plus custom attributes
format_module_attributes: [:moduledoc, :doc, :custom_doc, :note, :example]
# Format testing attributes
format_module_attributes: [:moduletag, :tag, :describetag]
Only string values are processed (boolean `false` values are preserved
unchanged). Use `default_doc_attributes/0` to get the standard list.
## Sigil Support
For convenient markdown handling, you can use the ~M sigil:
import DprintMarkdownFormatter.Sigil
# Raw markdown
markdown = ~M\"\"\"
# Hello World
Some content here.
\"\"\"
"""
@behaviour Mix.Tasks.Format
require Logger
alias DprintMarkdownFormatter.Config
alias DprintMarkdownFormatter.Error
alias DprintMarkdownFormatter.Validator
@doc """
Returns the features supported by this formatter plugin.
This plugin supports:
- The ~M sigil for markdown content
- Files with .md and .markdown extensions (pure markdown)
- Files with .ex and .exs extensions (module attributes only)
"""
@impl Mix.Tasks.Format
@spec features(keyword()) :: [sigils: [atom()], extensions: [binary()]]
def features(_opts) do
[sigils: [:M], extensions: [".md", ".markdown", ".ex", ".exs"]]
end
@doc """
Mix.Tasks.Format implementation.
This is the actual function used by Mix formatter. It calls `format_with_errors/2` internally
and returns the original content if formatting fails.
## Examples
iex> DprintMarkdownFormatter.format("# Hello World", [])
"# Hello World\\n"
iex> DprintMarkdownFormatter.format("# Hello World", extension: ".md")
"# Hello World\\n"
iex> DprintMarkdownFormatter.format("# Hello World", sigil: :M)
"# Hello World"
iex> DprintMarkdownFormatter.format("# Hello World", line_width: 60)
"# Hello World\\n"
iex> DprintMarkdownFormatter.format("* Item 1\\n* Item 2", unordered_list_kind: :asterisks)
"* Item 1\\n* Item 2\\n"
"""
@impl Mix.Tasks.Format
@spec format(String.t(), keyword()) :: String.t()
def format(contents, opts) when is_binary(contents) and is_list(opts) do
case format_with_errors(contents, opts) do
{:ok, formatted} -> formatted
{:error, _error} -> contents
end
end
@doc """
Formats markdown text with error details.
Returns `{:ok, formatted_content}` on success, or `{:error, error}` on failure.
This provides more detailed error information than the `Mix.Tasks.Format` version.
## Examples
iex> DprintMarkdownFormatter.format_with_errors("# Hello World", [])
{:ok, "# Hello World\\n"}
iex> DprintMarkdownFormatter.format_with_errors("# Hello", line_width: 100)
{:ok, "# Hello\\n"}
# Error cases return detailed error information (e.g., invalid content types)
# {:error, %DprintMarkdownFormatter.Error.ValidationError{}} would be returned for invalid inputs
"""
@spec format_with_errors(String.t(), keyword()) :: {:ok, String.t()} | {:error, Error.t()}
def format_with_errors(contents, opts) when is_binary(contents) and is_list(opts) do
with {:ok, validated_contents} <- Validator.validate_content(contents),
{:ok, validated_opts} <- Validator.validate_options(opts),
{:ok, content_type} <- determine_content_type(validated_opts),
{:ok, formatted} <- do_format(content_type, validated_contents, validated_opts) do
{:ok, formatted}
else
{:error, _error} = error_result ->
error_result
end
end
@doc """
Returns the default list of module attributes that are formatted when
`format_module_attributes: true`.
This list includes the most commonly used documentation attributes in Elixir
projects.
## Examples
iex> DprintMarkdownFormatter.default_doc_attributes()
[:moduledoc, :doc, :typedoc, :shortdoc, :deprecated]
# Extend with custom attributes
custom_attrs = DprintMarkdownFormatter.default_doc_attributes() ++ [:custom_doc, :note]
"""
@spec default_doc_attributes() :: [atom()]
def default_doc_attributes do
Config.default_doc_attributes()
end
# Private helpers
defp determine_content_type(opts) do
content_type =
cond do
Keyword.has_key?(opts, :sigil) ->
:sigil
Keyword.get(opts, :extension) in [".ex", ".exs"] ->
:elixir_source
Keyword.get(opts, :extension) in [".md", ".markdown"] ->
:markdown
true ->
:markdown
end
{:ok, content_type}
end
defp do_format(:markdown, contents, opts), do: format_markdown(contents, opts)
defp do_format(:elixir_source, contents, opts), do: format_elixir_source(contents, opts)
defp do_format(:sigil, contents, opts), do: format_sigil(contents, opts)
defp format_markdown(contents, opts) do
with {:ok, config} <- get_config(),
{:ok, merged_config} <- merge_runtime_options(config, opts),
{:ok, formatted} <- call_nif(contents, merged_config) do
{:ok, formatted}
else
{:error, error} -> {:error, error}
end
end
defp format_sigil(contents, opts) do
case format_markdown(contents, opts) do
{:ok, formatted} ->
# Remove trailing newline that dprint adds for consistency with sigil usage
{:ok, String.trim_trailing(formatted, "\n")}
{:error, _error} = error_result ->
error_result
end
end
defp format_elixir_source(contents, opts) do
with {:ok, config} <- get_config(),
{:ok, merged_config} <- merge_runtime_options(config, opts),
doc_attributes <- Config.resolve_module_attributes(merged_config),
{:ok, formatted_content} <-
format_module_attributes(contents, doc_attributes, merged_config) do
{:ok, formatted_content}
else
{:error, error} -> {:error, error}
end
end
defp get_config do
config = Config.load()
{:ok, config}
rescue
_error ->
default_config = Config.default()
{:ok, default_config}
end
defp merge_runtime_options(config, opts) do
case Validator.validate_config(config) do
{:ok, validated_config} ->
try do
{runtime_dprint_opts, _format_opts} =
Keyword.split(opts, [
:line_width,
:text_wrap,
:emphasis_kind,
:strong_kind,
:new_line_kind,
:unordered_list_kind
])
merged_config = Config.merge(validated_config, runtime_dprint_opts)
Validator.validate_config(merged_config)
rescue
error ->
{:error, Error.config_error("Failed to merge runtime options", original_error: error)}
end
{:error, error} ->
{:error, error}
end
end
defp call_nif(contents, config) do
nif_config = Config.to_nif_config(config)
case DprintMarkdownFormatter.Native.format_markdown(contents, nif_config) do
{:ok, formatted} ->
{:ok, formatted}
{:error, reason} ->
{:error, Error.nif_error("NIF formatting failed", original_error: reason)}
end
end
defp format_module_attributes(content, doc_attributes, config) do
with {:ok, ast} <- parse_elixir_source(content),
{:ok, patches} <- collect_patches_for_doc_attributes(ast, doc_attributes, config),
{:ok, patched_content} <- apply_patches(content, patches) do
{:ok, patched_content}
else
{:error, error} -> {:error, error}
end
end
defp parse_elixir_source(content) do
case Sourceror.parse_string(content) do
{:ok, ast} ->
{:ok, ast}
{:error, error} ->
{:error, Error.parse_error("Failed to parse Elixir source", original_error: error)}
end
end
defp apply_patches(content, patches) do
patched_content =
content
|> Sourceror.patch_string(patches)
|> final_cleanup_whitespace()
{:ok, patched_content}
rescue
error -> {:error, Error.format_error("Failed to apply patches", original_error: error)}
end
defp collect_patches_for_doc_attributes(ast, doc_attributes, config) do
{_updated_ast, patches} =
Macro.postwalk(ast, [], fn node, acc ->
process_node_for_patches(node, doc_attributes, config, acc)
end)
{:ok, patches}
rescue
error -> {:error, Error.format_error("Failed to collect patches", original_error: error)}
end
defp process_node_for_patches(node, doc_attributes, config, acc) do
case node do
# Handle both heredoc and simple string patterns: @moduledoc """content""" or @moduledoc "content"
{:@, _meta, [{attr, _attr_meta, [{:__block__, block_meta, [doc_content]} = string_node]}]}
when is_atom(attr) and is_binary(doc_content) ->
process_doc_attribute(
node,
attr,
doc_content,
string_node,
block_meta,
doc_attributes,
config,
acc
)
_other_node ->
{node, acc}
end
end
defp process_doc_attribute(
node,
attr,
doc_content,
string_node,
block_meta,
doc_attributes,
config,
acc
) do
if attr in doc_attributes do
case format_markdown_content(doc_content, config) do
{:ok, formatted} when formatted != doc_content ->
create_patch_for_formatted_content(node, formatted, string_node, block_meta, acc)
{:ok, _unchanged} ->
{node, acc}
{:error, _error} ->
{node, acc}
end
else
{node, acc}
end
end
defp create_patch_for_formatted_content(node, formatted, string_node, block_meta, acc) do
range = Sourceror.get_range(string_node)
delimiter = block_meta[:delimiter] || "\"\"\""
replacement = build_replacement_string(formatted, delimiter)
patch = %Sourceror.Patch{
range: range,
change: replacement
}
{node, [patch | acc]}
end
defp build_replacement_string(formatted, delimiter) do
if delimiter == "\"" do
build_simple_string_replacement(formatted)
else
build_heredoc_replacement(formatted)
end
end
defp build_simple_string_replacement(formatted) do
if String.contains?(formatted, "\n") do
# Convert to heredoc if content becomes multi-line
indented_content = indent_content_for_heredoc(formatted)
"\"\"\"\n#{indented_content}\n\"\"\""
else
# Keep as simple string
"\"#{formatted}\""
end
end
defp build_heredoc_replacement(formatted) do
# Heredoc format - preserve as heredoc
indented_content =
formatted
# Clean empty lines before indenting
|> clean_empty_lines()
|> indent_content_for_heredoc()
"\"\"\"\n#{indented_content}\n\"\"\""
end
defp indent_content_for_heredoc(content) do
# Keep content without additional indentation for heredoc
content
|> String.split("\n")
|> Enum.map_join("\n", fn line ->
if String.trim(line) == "" do
""
else
line
end
end)
end
defp format_markdown_content(content, config) do
nif_config = Config.to_nif_config(config)
# Clean up content by removing leading indentation and normalizing empty lines
clean_content = normalize_heredoc_content(content)
case DprintMarkdownFormatter.Native.format_markdown(clean_content, nif_config) do
{:ok, formatted} ->
# For heredocs, remove the trailing newline that dprint adds
# The heredoc structure will handle proper formatting
{:ok, String.trim_trailing(formatted, "\n")}
{:error, reason} ->
{:error, Error.nif_error("Failed to format markdown content", original_error: reason)}
end
end
defp final_cleanup_whitespace(content) do
# Final pass to clean up any remaining whitespace-only lines in heredocs
# This catches cases where Sourceror or intermediate processing leaves trailing spaces
String.replace(content, ~r/^[ \t]+$/m, "")
end
defp clean_empty_lines(content) do
# Use regex to replace any line that contains only whitespace with an empty line
String.replace(content, ~r/^[ \t]+$/m, "")
end
defp normalize_heredoc_content(content) do
# Simple approach: split lines, find common indentation, remove it
lines = String.split(content, "\n")
# Filter out empty lines to find minimum indentation
content_lines = Enum.reject(lines, &(String.trim(&1) == ""))
if Enum.empty?(content_lines) do
""
else
# Find the minimum number of leading spaces
min_spaces =
content_lines
|> Enum.map(fn line ->
leading_spaces = String.length(line) - String.length(String.trim_leading(line, " "))
leading_spaces
end)
|> Enum.min()
# Remove that many spaces from the beginning of each line
Enum.map_join(lines, "\n", fn line ->
process_line_for_normalization(line, min_spaces)
end)
end
end
defp process_line_for_normalization(line, min_spaces) do
# Always check if line is empty first, regardless of spaces
if String.trim(line) == "" do
""
else
# Remove min_spaces from the start, but don't go past the line length
spaces_to_remove =
min(min_spaces, String.length(line) - String.length(String.trim_leading(line, " ")))
processed_line = String.slice(line, spaces_to_remove..-1//1)
# If the line becomes empty or contains only whitespace after processing, make it truly empty
if String.trim(processed_line) == "" do
""
else
processed_line
end
end
end
end