diff --git a/lib/dprint_markdown_formatter.ex b/lib/dprint_markdown_formatter.ex index 4215826..237ba46 100644 --- a/lib/dprint_markdown_formatter.ex +++ b/lib/dprint_markdown_formatter.ex @@ -42,9 +42,11 @@ defmodule DprintMarkdownFormatter do `:auto`) - `:unordered_list_kind` - Unordered list style: `:dashes`, `:asterisks` (default: `:dashes`) + - `:heading_kind` - Heading style for level 1/2 headings: `:atx` (`#`/`##`), + `:setext` (`===`/`---`) (default: `:atx`) **Note:** Configuration values can be provided as atoms (`:never`) or strings - (`"never"`). Atoms are preferred for consistency with Elixir conventions. + (`"never"`) via `mix.exs`. Runtime options passed to `format/2` must be atoms. - `:format_module_attributes` - Configure which module attributes to format. Supports four input types for maximum flexibility: @@ -253,9 +255,14 @@ defmodule DprintMarkdownFormatter do # Remove dprint options to pass only valid Elixir formatter options elixir_opts = Keyword.drop(opts, @dprint_opts) - case Code.format_string!(formatted_content, elixir_opts) do - [] -> {:ok, ""} - formatted -> {:ok, IO.iodata_to_binary([formatted, ?\n])} + formatted = + formatted_content + |> Code.format_string!(elixir_opts) + |> IO.iodata_to_binary() + + case formatted do + "" -> {:ok, ""} + _non_empty -> {:ok, formatted <> "\n"} end end end diff --git a/lib/dprint_markdown_formatter/config.ex b/lib/dprint_markdown_formatter/config.ex index c6de836..5adb1b0 100644 --- a/lib/dprint_markdown_formatter/config.ex +++ b/lib/dprint_markdown_formatter/config.ex @@ -10,8 +10,28 @@ defmodule DprintMarkdownFormatter.Config do @typep strong_kind_option() :: :asterisks | :underscores @typep new_line_kind_option() :: :auto | :lf | :crlf @typep unordered_list_kind_option() :: :dashes | :asterisks + @typep heading_kind_option() :: :atx | :setext @typep module_attributes_option() :: nil | boolean() | [atom()] + @typep nif_config_map() :: %{ + line_width: pos_integer(), + text_wrap: atom(), + emphasis_kind: atom(), + strong_kind: atom(), + new_line_kind: atom(), + unordered_list_kind: atom(), + heading_kind: atom() + } + + @atom_choices %{ + text_wrap: [:always, :never, :maintain], + emphasis_kind: [:asterisks, :underscores], + strong_kind: [:asterisks, :underscores], + new_line_kind: [:auto, :lf, :crlf], + unordered_list_kind: [:dashes, :asterisks], + heading_kind: [:atx, :setext] + } + typed_structor enforce: true do field :line_width, pos_integer(), default: 80 field :text_wrap, text_wrap_option(), default: :always @@ -19,6 +39,7 @@ defmodule DprintMarkdownFormatter.Config do field :strong_kind, strong_kind_option(), default: :asterisks field :new_line_kind, new_line_kind_option(), default: :auto field :unordered_list_kind, unordered_list_kind_option(), default: :dashes + field :heading_kind, heading_kind_option(), default: :atx field :format_module_attributes, module_attributes_option(), default: nil end @@ -35,6 +56,7 @@ defmodule DprintMarkdownFormatter.Config do strong_kind: :asterisks, new_line_kind: :auto, unordered_list_kind: :dashes, + heading_kind: :atx, format_module_attributes: nil } """ @@ -59,12 +81,8 @@ defmodule DprintMarkdownFormatter.Config do ## Examples # With valid configuration in mix.exs - iex> DprintMarkdownFormatter.Config.load() - %DprintMarkdownFormatter.Config{line_width: 80, text_wrap: :always} - - # With invalid configuration values (logs warnings and uses defaults) - iex> DprintMarkdownFormatter.Config.load() - %DprintMarkdownFormatter.Config{line_width: 80, text_wrap: :always} + DprintMarkdownFormatter.Config.load() + #=> %DprintMarkdownFormatter.Config{line_width: 80, text_wrap: :always} """ @spec load() :: t() def load do @@ -87,6 +105,18 @@ defmodule DprintMarkdownFormatter.Config do iex> DprintMarkdownFormatter.Config.from_keyword([format_module_attributes: true]) %DprintMarkdownFormatter.Config{format_module_attributes: true} + + iex> DprintMarkdownFormatter.Config.from_keyword([heading_kind: :setext]).heading_kind + :setext + + iex> DprintMarkdownFormatter.Config.from_keyword([heading_kind: "setext"]).heading_kind + :setext + + iex> DprintMarkdownFormatter.Config.from_keyword([heading_kind: "atx"]).heading_kind + :atx + + iex> DprintMarkdownFormatter.Config.from_keyword([heading_kind: :invalid]).heading_kind + :atx """ @spec from_keyword(keyword()) :: t() def from_keyword(opts) when is_list(opts) do @@ -120,6 +150,10 @@ defmodule DprintMarkdownFormatter.Config do iex> config = %DprintMarkdownFormatter.Config{text_wrap: :always} iex> DprintMarkdownFormatter.Config.merge(config, [text_wrap: :never, line_width: 120]) %DprintMarkdownFormatter.Config{text_wrap: :never, line_width: 120} + + iex> config = %DprintMarkdownFormatter.Config{heading_kind: :atx} + iex> DprintMarkdownFormatter.Config.merge(config, [heading_kind: :setext]).heading_kind + :setext """ @spec merge(t(), keyword()) :: t() def merge(%__MODULE__{} = config, opts) when is_list(opts) do @@ -147,16 +181,17 @@ defmodule DprintMarkdownFormatter.Config do iex> config = %DprintMarkdownFormatter.Config{line_width: 100, text_wrap: :never} iex> DprintMarkdownFormatter.Config.to_nif_config(config) - %{line_width: 100, text_wrap: :never, emphasis_kind: :asterisks} + %{ + line_width: 100, + text_wrap: :never, + emphasis_kind: :asterisks, + strong_kind: :asterisks, + new_line_kind: :auto, + unordered_list_kind: :dashes, + heading_kind: :atx + } """ - @spec to_nif_config(t()) :: %{ - line_width: non_neg_integer(), - text_wrap: atom(), - emphasis_kind: atom(), - strong_kind: atom(), - new_line_kind: atom(), - unordered_list_kind: atom() - } + @spec to_nif_config(t()) :: nif_config_map() def to_nif_config(%__MODULE__{} = config) do %{ line_width: config.line_width, @@ -164,7 +199,8 @@ defmodule DprintMarkdownFormatter.Config do emphasis_kind: config.emphasis_kind, strong_kind: config.strong_kind, new_line_kind: config.new_line_kind, - unordered_list_kind: config.unordered_list_kind + unordered_list_kind: config.unordered_list_kind, + heading_kind: config.heading_kind } end @@ -202,91 +238,55 @@ defmodule DprintMarkdownFormatter.Config do defp validate_option(:line_width, value), do: {:error, "must be a positive integer, got: #{inspect(value)}"} - defp validate_option(:text_wrap, value) when value in [:always, :never, :maintain], - do: {:ok, value} + defp validate_option(key, value) when is_map_key(@atom_choices, key), + do: validate_atom_choice(value, @atom_choices[key]) - defp validate_option(:text_wrap, value) when is_binary(value) do - case value do - "always" -> {:ok, :always} - "never" -> {:ok, :never} - "maintain" -> {:ok, :maintain} - _invalid_value -> {:error, "must be :always, :never, or :maintain, got: #{inspect(value)}"} - end - end - - defp validate_option(:text_wrap, value), - do: {:error, "must be :always, :never, or :maintain, got: #{inspect(value)}"} - - defp validate_option(:emphasis_kind, value) when value in [:asterisks, :underscores], - do: {:ok, value} + defp validate_option(:format_module_attributes, nil), do: {:ok, nil} + defp validate_option(:format_module_attributes, value) when is_boolean(value), do: {:ok, value} - defp validate_option(:emphasis_kind, value) when is_binary(value) do - case value do - "asterisks" -> {:ok, :asterisks} - "underscores" -> {:ok, :underscores} - _invalid_value -> {:error, "must be :asterisks or :underscores, got: #{inspect(value)}"} + defp validate_option(:format_module_attributes, value) when is_list(value) do + if Enum.all?(value, &is_atom/1) do + {:ok, value} + else + {:error, "must be a list of atoms, got: #{inspect(value)}"} end end - defp validate_option(:emphasis_kind, value), - do: {:error, "must be :asterisks or :underscores, got: #{inspect(value)}"} + defp validate_option(:format_module_attributes, value), + do: {:error, "must be nil, boolean, or list of atoms, got: #{inspect(value)}"} - defp validate_option(:strong_kind, value) when value in [:asterisks, :underscores], - do: {:ok, value} + defp validate_option(key, value), + do: {:error, "unknown configuration option #{key} with value #{inspect(value)}"} - defp validate_option(:strong_kind, value) when is_binary(value) do - case value do - "asterisks" -> {:ok, :asterisks} - "underscores" -> {:ok, :underscores} - _invalid_value -> {:error, "must be :asterisks or :underscores, got: #{inspect(value)}"} - end + defp validate_atom_choice(value, choices) when is_atom(value) do + if value in choices, + do: {:ok, value}, + else: {:error, format_choice_error(choices, value)} end - defp validate_option(:strong_kind, value), - do: {:error, "must be :asterisks or :underscores, got: #{inspect(value)}"} - - defp validate_option(:new_line_kind, value) when value in [:auto, :lf, :crlf], do: {:ok, value} - - defp validate_option(:new_line_kind, value) when is_binary(value) do - case value do - "auto" -> {:ok, :auto} - "lf" -> {:ok, :lf} - "crlf" -> {:ok, :crlf} - _invalid_value -> {:error, "must be :auto, :lf, or :crlf, got: #{inspect(value)}"} + defp validate_atom_choice(value, choices) when is_binary(value) do + case Enum.find(choices, &(Atom.to_string(&1) == value)) do + nil -> {:error, format_choice_error(choices, value)} + atom -> {:ok, atom} end end - defp validate_option(:new_line_kind, value), - do: {:error, "must be :auto, :lf, or :crlf, got: #{inspect(value)}"} - - defp validate_option(:unordered_list_kind, value) when value in [:dashes, :asterisks], - do: {:ok, value} + defp validate_atom_choice(value, choices), + do: {:error, format_choice_error(choices, value)} - defp validate_option(:unordered_list_kind, value) when is_binary(value) do - case value do - "dashes" -> {:ok, :dashes} - "asterisks" -> {:ok, :asterisks} - _invalid_value -> {:error, "must be :dashes or :asterisks, got: #{inspect(value)}"} - end - end + defp format_choice_error(choices, value), + do: "must be #{format_choices(choices)}, got: #{inspect(value)}" - defp validate_option(:unordered_list_kind, value), - do: {:error, "must be :dashes or :asterisks, got: #{inspect(value)}"} + defp format_choices(choices) do + inspected = Enum.map(choices, &inspect/1) - defp validate_option(:format_module_attributes, nil), do: {:ok, nil} - defp validate_option(:format_module_attributes, value) when is_boolean(value), do: {:ok, value} + case inspected do + [a, b] -> + "#{a} or #{b}" - defp validate_option(:format_module_attributes, value) when is_list(value) do - if Enum.all?(value, &is_atom/1) do - {:ok, value} - else - {:error, "must be a list of atoms, got: #{inspect(value)}"} + list -> + {butlast, [last]} = Enum.split(list, -1) + "#{Enum.join(butlast, ", ")}, or #{last}" end end - - defp validate_option(:format_module_attributes, value), - do: {:error, "must be nil, boolean, or list of atoms, got: #{inspect(value)}"} - - defp validate_option(key, value), - do: {:error, "unknown configuration option #{key} with value #{inspect(value)}"} end diff --git a/lib/dprint_markdown_formatter/native.ex b/lib/dprint_markdown_formatter/native.ex index ac515db..f76edc0 100644 --- a/lib/dprint_markdown_formatter/native.ex +++ b/lib/dprint_markdown_formatter/native.ex @@ -25,6 +25,7 @@ defmodule DprintMarkdownFormatter.Native do @typep strong_kind_option() :: :asterisks | :underscores @typep new_line_kind_option() :: :auto | :lf | :crlf @typep unordered_list_kind_option() :: :dashes | :asterisks + @typep heading_kind_option() :: :atx | :setext @typep format_options() :: %{ line_width: pos_integer(), @@ -32,7 +33,8 @@ defmodule DprintMarkdownFormatter.Native do emphasis_kind: emphasis_kind_option(), strong_kind: strong_kind_option(), new_line_kind: new_line_kind_option(), - unordered_list_kind: unordered_list_kind_option() + unordered_list_kind: unordered_list_kind_option(), + heading_kind: heading_kind_option() } @doc """ @@ -53,7 +55,7 @@ defmodule DprintMarkdownFormatter.Native do ## Examples - iex> options = %{line_width: 80, text_wrap: :always, emphasis_kind: :asterisks, strong_kind: :asterisks, new_line_kind: :auto, unordered_list_kind: :dashes} + iex> options = %{line_width: 80, text_wrap: :always, emphasis_kind: :asterisks, strong_kind: :asterisks, new_line_kind: :auto, unordered_list_kind: :dashes, heading_kind: :atx} iex> DprintMarkdownFormatter.Native.format_markdown("# Hello World", options) {:ok, "# Hello World\\n"} diff --git a/lib/dprint_markdown_formatter/validator.ex b/lib/dprint_markdown_formatter/validator.ex index eb9890c..db4aa3c 100644 --- a/lib/dprint_markdown_formatter/validator.ex +++ b/lib/dprint_markdown_formatter/validator.ex @@ -91,6 +91,7 @@ defmodule DprintMarkdownFormatter.Validator do {:ok, _strong_kind} <- validate_strong_kind(config.strong_kind), {:ok, _new_line_kind} <- validate_new_line_kind(config.new_line_kind), {:ok, _unordered_list_kind} <- validate_unordered_list_kind(config.unordered_list_kind), + {:ok, _heading_kind} <- validate_heading_kind(config.heading_kind), {:ok, _format_module_attributes} <- validate_format_module_attributes(config.format_module_attributes) do {:ok, config} @@ -162,6 +163,7 @@ defmodule DprintMarkdownFormatter.Validator do defp validate_option_value(:strong_kind, value), do: validate_strong_kind(value) defp validate_option_value(:new_line_kind, value), do: validate_new_line_kind(value) defp validate_option_value(:unordered_list_kind, value), do: validate_unordered_list_kind(value) + defp validate_option_value(:heading_kind, value), do: validate_heading_kind(value) defp validate_option_value(:extension, value), do: validate_extension(value) defp validate_option_value(:sigil, value), do: validate_sigil(value) @@ -207,6 +209,9 @@ defmodule DprintMarkdownFormatter.Validator do defp validate_unordered_list_kind(value), do: validate_atom_choice(value, :unordered_list_kind, [:dashes, :asterisks]) + defp validate_heading_kind(value), + do: validate_atom_choice(value, :heading_kind, [:atx, :setext]) + defp validate_atom_choice(value, field, valid_choices) when is_atom(value) do if value in valid_choices do {:ok, value} diff --git a/mix.exs b/mix.exs index eb73c28..cd29b48 100644 --- a/mix.exs +++ b/mix.exs @@ -36,7 +36,7 @@ defmodule DprintMarkdownFormatter.MixProject do {:rustler_precompiled, "~> 0.8"}, {:rustler, "~> 0.36.0", optional: true}, {:sourceror, "~> 1.0"}, - {:typed_structor, "~> 0.5.0"}, + {:typed_structor, "~> 0.5"}, {:mimic, "~> 1.7", only: :test}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, diff --git a/native/dprint_markdown_formatter/Cargo.toml b/native/dprint_markdown_formatter/Cargo.toml index ca23197..d30d3fc 100644 --- a/native/dprint_markdown_formatter/Cargo.toml +++ b/native/dprint_markdown_formatter/Cargo.toml @@ -9,7 +9,7 @@ crate-type = ["cdylib"] [dependencies] rustler = { version = "0.36.0" } -dprint-plugin-markdown = "0.18.0" +dprint-plugin-markdown = "0.21.1" dprint-core = "0.67.4" [features] diff --git a/native/dprint_markdown_formatter/src/lib.rs b/native/dprint_markdown_formatter/src/lib.rs index 09a2bbb..8ccb3cd 100644 --- a/native/dprint_markdown_formatter/src/lib.rs +++ b/native/dprint_markdown_formatter/src/lib.rs @@ -1,5 +1,8 @@ use dprint_core::configuration::NewLineKind; -use dprint_plugin_markdown::{configuration::Configuration, format_text}; +use dprint_plugin_markdown::configuration::{ + Configuration, EmphasisKind, HeadingKind, StrongKind, TextWrap, UnorderedListKind, +}; +use dprint_plugin_markdown::format_text; use rustler::{Atom, Term}; use std::collections::HashMap; @@ -11,6 +14,7 @@ rustler::atoms! { strong_kind, new_line_kind, unordered_list_kind, + heading_kind, always, never, maintain, @@ -20,10 +24,12 @@ rustler::atoms! { lf, crlf, dashes, + atx, + setext, } /// Simple NIF function that receives a config map from Elixir -/// The map contains only the 6 dprint-related fields (no format_module_attributes) +/// The map contains only the 7 dprint-related fields (no format_module_attributes) /// Elixir is the single source of truth for configuration validation #[rustler::nif] fn format_markdown(text: String, config: HashMap) -> Result { @@ -55,6 +61,7 @@ fn build_dprint_config(map: HashMap) -> Result) -> Result) -> Result, -) -> Result { - let wrap_atom = map - .get(&text_wrap()) - .ok_or("Missing text_wrap")? - .decode::() - .map_err(|_| "Invalid text_wrap")?; - - match wrap_atom { - atom if atom == always() => Ok(dprint_plugin_markdown::configuration::TextWrap::Always), - atom if atom == never() => Ok(dprint_plugin_markdown::configuration::TextWrap::Never), - atom if atom == maintain() => Ok(dprint_plugin_markdown::configuration::TextWrap::Maintain), - _ => Err("Invalid text_wrap value".to_string()), - } -} - -/// Build emphasis kind configuration from config map -fn build_emphasis_kind( - map: &HashMap, -) -> Result { - let kind_atom = map - .get(&emphasis_kind()) - .ok_or("Missing emphasis_kind")? - .decode::() - .map_err(|_| "Invalid emphasis_kind")?; - - match kind_atom { - atom if atom == asterisks() => { - Ok(dprint_plugin_markdown::configuration::EmphasisKind::Asterisks) +/// Decode an atom-valued config entry into a target enum variant. +macro_rules! build_enum_option { + ( + $fn_name:ident, + $key:ident, + $ret:ty, + { $( $atom_fn:ident => $variant:expr ),+ $(,)? } + ) => { + fn $fn_name(map: &HashMap) -> Result<$ret, String> { + let decoded = map + .get(&$key()) + .ok_or_else(|| format!("Missing {}", stringify!($key)))? + .decode::() + .map_err(|_| format!("Invalid {}", stringify!($key)))?; + + match decoded { + $( atom if atom == $atom_fn() => Ok($variant), )+ + _ => Err(format!("Invalid {} value", stringify!($key))), + } } - atom if atom == underscores() => { - Ok(dprint_plugin_markdown::configuration::EmphasisKind::Underscores) - } - _ => Err("Invalid emphasis_kind value".to_string()), - } + }; } -/// Build strong kind configuration from config map -fn build_strong_kind( - map: &HashMap, -) -> Result { - let kind_atom = map - .get(&strong_kind()) - .ok_or("Missing strong_kind")? - .decode::() - .map_err(|_| "Invalid strong_kind")?; - - match kind_atom { - atom if atom == asterisks() => { - Ok(dprint_plugin_markdown::configuration::StrongKind::Asterisks) - } - atom if atom == underscores() => { - Ok(dprint_plugin_markdown::configuration::StrongKind::Underscores) - } - _ => Err("Invalid strong_kind value".to_string()), - } -} - -/// Build new line kind configuration from config map -fn build_new_line_kind(map: &HashMap) -> Result { - let kind_atom = map - .get(&new_line_kind()) - .ok_or("Missing new_line_kind")? - .decode::() - .map_err(|_| "Invalid new_line_kind")?; - - match kind_atom { - atom if atom == auto() => Ok(NewLineKind::Auto), - atom if atom == lf() => Ok(NewLineKind::LineFeed), - atom if atom == crlf() => Ok(NewLineKind::CarriageReturnLineFeed), - _ => Err("Invalid new_line_kind value".to_string()), - } -} - -/// Build unordered list kind configuration from config map -fn build_unordered_list_kind( - map: &HashMap, -) -> Result { - let kind_atom = map - .get(&unordered_list_kind()) - .ok_or("Missing unordered_list_kind")? - .decode::() - .map_err(|_| "Invalid unordered_list_kind")?; - - match kind_atom { - atom if atom == dashes() => { - Ok(dprint_plugin_markdown::configuration::UnorderedListKind::Dashes) - } - atom if atom == asterisks() => { - Ok(dprint_plugin_markdown::configuration::UnorderedListKind::Asterisks) - } - _ => Err("Invalid unordered_list_kind value".to_string()), - } -} +build_enum_option!(build_text_wrap, text_wrap, TextWrap, { + always => TextWrap::Always, + never => TextWrap::Never, + maintain => TextWrap::Maintain, +}); + +build_enum_option!(build_emphasis_kind, emphasis_kind, EmphasisKind, { + asterisks => EmphasisKind::Asterisks, + underscores => EmphasisKind::Underscores, +}); + +build_enum_option!(build_strong_kind, strong_kind, StrongKind, { + asterisks => StrongKind::Asterisks, + underscores => StrongKind::Underscores, +}); + +build_enum_option!(build_new_line_kind, new_line_kind, NewLineKind, { + auto => NewLineKind::Auto, + lf => NewLineKind::LineFeed, + crlf => NewLineKind::CarriageReturnLineFeed, +}); + +build_enum_option!(build_unordered_list_kind, unordered_list_kind, UnorderedListKind, { + dashes => UnorderedListKind::Dashes, + asterisks => UnorderedListKind::Asterisks, +}); + +build_enum_option!(build_heading_kind, heading_kind, HeadingKind, { + atx => HeadingKind::Atx, + setext => HeadingKind::Setext, +}); rustler::init!("Elixir.DprintMarkdownFormatter.Native"); diff --git a/test/config_test.exs b/test/config_test.exs new file mode 100644 index 0000000..72a0e48 --- /dev/null +++ b/test/config_test.exs @@ -0,0 +1,4 @@ +defmodule DprintMarkdownFormatter.ConfigTest do + use ExUnit.Case, async: true + doctest DprintMarkdownFormatter.Config +end diff --git a/test/dprint_markdown_formatter_test.exs b/test/dprint_markdown_formatter_test.exs index 0f863f4..13ab5af 100644 --- a/test/dprint_markdown_formatter_test.exs +++ b/test/dprint_markdown_formatter_test.exs @@ -262,7 +262,8 @@ defmodule DprintMarkdownFormatterTest do emphasis_kind: :asterisks, strong_kind: :asterisks, new_line_kind: :auto, - unordered_list_kind: :asterisks + unordered_list_kind: :asterisks, + heading_kind: :atx } result = DprintMarkdownFormatter.Native.format_markdown(input, nif_config) @@ -282,6 +283,33 @@ defmodule DprintMarkdownFormatterTest do end end + describe "format/2 with heading_kind" do + test "emits setext headings when heading_kind: :setext" do + assert DprintMarkdownFormatter.format("# Hello", heading_kind: :setext) == + "Hello\n=====\n" + end + + test "emits atx headings by default" do + assert DprintMarkdownFormatter.format("# Hello", []) == "# Hello\n" + end + + test "runtime opts override mix.exs heading_kind" do + stub(Mix.Project, :config, fn -> + [dprint_markdown_formatter: [heading_kind: :setext]] + end) + + assert DprintMarkdownFormatter.format("# Hello", heading_kind: :atx) == "# Hello\n" + end + + test "invalid heading_kind does not crash; format/2 returns original contents" do + assert DprintMarkdownFormatter.format("# Hello", heading_kind: :bogus) == "# Hello" + end + + test "runtime opts must be atoms; string values are rejected" do + assert DprintMarkdownFormatter.format("# Hello", heading_kind: "setext") == "# Hello" + end + end + describe "Mix.Tasks.Format behavior" do test "features/1 returns correct sigils and extensions" do features = DprintMarkdownFormatter.features([])