Skip to content

Commit

Permalink
docs: add doc_fields guide
Browse files Browse the repository at this point in the history
  • Loading branch information
fahchen committed Jul 7, 2024
1 parent 996219e commit 59370fe
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 8 deletions.
171 changes: 171 additions & 0 deletions guides/plugins/doc_fields.md
Original file line number Diff line number Diff line change
@@ -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.
```
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions test/guides/plugins/doc_fields_test.exs
Original file line number Diff line number Diff line change
@@ -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
17 changes: 9 additions & 8 deletions test/support/guide_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,33 @@ 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!()
|> TypedStructor.TestCase.format_types()
|> 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")
Expand Down

0 comments on commit 59370fe

Please sign in to comment.