diff --git a/guides/plugins/doc_fields.md b/guides/plugins/doc_fields.md new file mode 100644 index 0000000..78ce7ed --- /dev/null +++ b/guides/plugins/doc_fields.md @@ -0,0 +1,171 @@ +# Add fields docs to the `@typedoc` + +## Implement +```elixir +defmodule Guides.Plugins.DocFields do + @moduledoc """ + The `DocFields` plugin generates documentation for fields and parameters. + Simply add the `:doc` option to the `field` and `parameter` macros to document them. + + ## Example + + use TypedStructor + + typed_structor do + @typedoc \""" + This is a user struct. + \""" + plugin Guides.Plugins.DocFields + + parameter :age, doc: "The age parameter." + + field :name, String.t(), doc: "The name of the user." + field :age, age, doc: "The age of the user." + end + + This will generate the following documentation for you: + + @typedoc \""" + This is a user struct. + + + ## Parameters + + | Name | Description | + |------|-------------| + |`:age` | The age parameter.| + + + ## Fields + + | Name | Type | Description | + |------|------|-------------| + |`:name` | `String.t() \| nil` | The name of the user.| + |`:age` | `age \| nil` | The age of the user.| + \""" + + @type t(age) :: %User{age: age | nil, name: String.t() | nil} + """ + + use TypedStructor.Plugin + + @impl TypedStructor.Plugin + defmacro before_definition(definition, _opts) do + quote do + @typedoc unquote(__MODULE__).__generate_doc__(unquote(definition), @typedoc) + + unquote(definition) + end + end + + def __generate_doc__(_definition, false), do: nil + + def __generate_doc__(definition, typedoc) do + parameters = + Enum.map(definition.parameters, fn parameter -> + name = Keyword.fetch!(parameter, :name) + doc = Keyword.get(parameter, :doc, "*not documented*") + + ["`#{inspect(name)}`", doc] + end) + + parameters_docs = + if length(parameters) > 0 do + """ + ## Parameters + + | Name | Description | + |------|-------------| + #{join_rows(parameters)} + """ + end + + fields = + Enum.map(definition.fields, fn field -> + name = Keyword.fetch!(field, :name) + + type = Keyword.fetch!(field, :type) + + type = + if Keyword.get(field, :enforce, false) or Keyword.has_key?(field, :default) do + Macro.to_string(type) + else + # escape `|` + "#{Macro.to_string(type)} \\| nil" + end + + doc = Keyword.get(field, :doc, "*not documented*") + + ["`#{inspect(name)}`", "`#{type}`", doc] + end) + + fields_docs = + if length(fields) > 0 do + """ + ## Fields + + | Name | Type | Description | + |------|------|-------------| + #{join_rows(fields)} + """ + end + + [parameters_docs, fields_docs] + |> Enum.reject(&is_nil/1) + |> case do + [] -> + typedoc + + docs -> + """ + #{typedoc} + + #{Enum.join(docs, "\n\n")} + """ + end + end + + defp join_rows(rows) do + Enum.map_join(rows, "\n", fn row -> "|" <> Enum.join(row, " | ") <> "|" end) + end +end +``` + +## Usage +```elixir +defmodule User do + @moduledoc false + + use TypedStructor + + typed_structor do + @typedoc """ + This is a user struct. + """ + plugin Guides.Plugins.DocFields + + parameter :age, doc: "The age parameter." + + field :name, String.t(), doc: "The name of the user." + field :age, age, doc: "The age of the user." + end +end +``` + +```elixir +iex> t User.t +@type t(age) :: %User{age: age | nil, name: String.t() | nil} + +This is a user struct. + +## Parameters + +Name | Description +:age | The age parameter. + +## Fields + +Name | Type | Description +:name | String.t() | nil | The name of the user. +:age | age | nil | The age of the user. +``` diff --git a/mix.exs b/mix.exs index fb90ff9..e5e1d00 100644 --- a/mix.exs +++ b/mix.exs @@ -36,6 +36,7 @@ defmodule TypedStructor.MixProject do "guides/plugins/registering_plugins_globally.md", "guides/plugins/accessible.md", "guides/plugins/reflection.md", + "guides/plugins/doc_fields.md", "guides/plugins/type_only_on_ecto_schema.md", "guides/plugins/primary_key_and_timestamps.md", "guides/plugins/derive_jason.md", diff --git a/test/guides/plugins/doc_fields_test.exs b/test/guides/plugins/doc_fields_test.exs new file mode 100644 index 0000000..4e00a20 --- /dev/null +++ b/test/guides/plugins/doc_fields_test.exs @@ -0,0 +1,39 @@ +defmodule Guides.Plugins.DocFieldsTest do + use TypedStructor.TestCase + + @tag :tmp_dir + test "works", ctx do + doc = + with_tmpmodule TestModule, ctx do + unquote( + "doc_fields.md" + |> TypedStructor.GuideCase.extract_code() + |> Code.string_to_quoted!() + ) + after + fetch_doc!(TestModule.User, {:type, :t, 1}) + end + + expected = """ + This is a user struct. + + + ## Parameters + + | Name | Description | + |------|-------------| + |`:age` | The age parameter.| + + + ## Fields + + | Name | Type | Description | + |------|------|-------------| + |`:name` | `String.t() | nil` | The name of the user.| + |`:age` | `age | nil` | The age of the user.| + + """ + + assert expected === doc + end +end diff --git a/test/support/guide_case.ex b/test/support/guide_case.ex index e308589..46d1379 100644 --- a/test/support/guide_case.ex +++ b/test/support/guide_case.ex @@ -6,22 +6,21 @@ defmodule TypedStructor.GuideCase do using opts do guide = Keyword.fetch!(opts, :guide) - file = Path.expand([__DIR__, "../../../", "guides/plugins/", guide]) - - ast = Code.string_to_quoted!(extract_code(file)) + ast = Code.string_to_quoted!(extract_code(guide)) quote do - Code.compiler_options(debug_info: true) + Code.compiler_options(debug_info: true, docs: true) {:module, _module_name, bytecode, _submodule} = unquote(ast) ExUnit.Case.register_module_attribute(__MODULE__, :bytecode) @bytecode bytecode - import unquote(__MODULE__), only: [types: 1] + import unquote(__MODULE__) end end + @spec types(binary()) :: binary() def types(bytecode) when is_binary(bytecode) do bytecode |> TypedStructor.TestCase.fetch_types!() @@ -29,9 +28,11 @@ defmodule TypedStructor.GuideCase do |> Kernel.<>("\n") end - defp extract_code(file) do - content = - File.read!(file) + @spec extract_code(String.t()) :: String.t() + def extract_code(filename) do + file = Path.expand([__DIR__, "../../../", "guides/plugins/", filename]) + + content = File.read!(file) content |> String.split("\n")