Skip to content

Commit aaffa08

Browse files
javobalazsejpcmac
andcommitted
feat: add :doc option to field macro
Co-authored-by: Balázs Jávorszky <javorszky.balazs@estyle.hu> Co-authored-by: Jean-Philippe Cugnet <jean-philippe@cugnet.eu>
1 parent 96b4fbe commit aaffa08

File tree

6 files changed

+180
-0
lines changed

6 files changed

+180
-0
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ Versioning](https://semver.org/spec/v2.0.0.html).
1313

1414
## [Unreleased]
1515

16+
### Added
17+
18+
* Add a `:doc` option to `field/3` to add field-specific descriptions in the
19+
`@typedoc` (by [@javobalazs](https://github.com/javobalazs)).
20+
1621
### Fixed
1722

1823
* Ensure the second argument to `field/2` is a type ([#45]).

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,30 @@ typedstruct do
203203
end
204204
```
205205

206+
You can also document individual fields:
207+
208+
```elixir
209+
typedstruct do
210+
@typedoc "A typed struct"
211+
212+
field :a_string, String.t(), doc: "just a series of letters"
213+
field :an_int, integer(), doc: "some explanation"
214+
end
215+
```
216+
217+
This generate the following `@typedoc`:
218+
219+
```elixir
220+
@typedoc """
221+
A typed struct
222+
223+
## Fields
224+
225+
- `a_string` - just a series of letters
226+
- `an_int` - some digits
227+
"""
228+
```
229+
206230
You can also document submodules this way:
207231

208232
```elixir

lib/typed_struct.ex

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# SPDX-FileCopyrightText: 2018-2022, 2025 Jean-Philippe Cugnet <jean-philippe@cugnet.eu>
22
# SPDX-FileCopyrightText: 2018 Marcin Górnik <marcin.gornik@gmail.com>
33
# SPDX-FileCopyrightText: 2022 Jonathan Chukinas <chukinas@gmail.com>
4+
# SPDX-FileCopyrightText: 2022 Balázs Jávorszky <javorszky.balazs@estyle.hu>
45
#
56
# SPDX-License-Identifier: MIT
67

@@ -16,6 +17,7 @@ defmodule TypedStruct do
1617
:ts_plugin_fields,
1718
:ts_fields,
1819
:ts_types,
20+
:ts_docs,
1921
:ts_enforce_keys
2022
]
2123

@@ -116,10 +118,46 @@ defmodule TypedStruct do
116118
@enforce_keys @ts_enforce_keys
117119
defstruct @ts_fields
118120

121+
TypedStruct.__typedoc__(@ts_docs)
119122
TypedStruct.__type__(@ts_types, unquote(opts))
120123
end
121124
end
122125

126+
@doc false
127+
defmacro __typedoc__(docs) do
128+
quote bind_quoted: [docs: docs] do
129+
field_docs =
130+
docs
131+
|> Enum.reverse()
132+
|> Enum.filter(fn {_, doc} -> !is_nil(doc) end)
133+
|> Enum.map(fn {name, doc} -> "- `#{name}` - #{doc}" end)
134+
135+
if field_docs != [] do
136+
# If there are field docs, we complete the `@typedoc` with field
137+
# documentation. However, if there is no `@typedoc` already, let’s emit
138+
# a warning instead.
139+
if Module.has_attribute?(__MODULE__, :typedoc) do
140+
@typedoc """
141+
#{@typedoc}
142+
143+
## Fields
144+
145+
#{Enum.join(field_docs, "\n")}
146+
"""
147+
else
148+
IO.warn(
149+
"""
150+
adding field documentation has no effect without a @typedoc
151+
152+
hint: add a @typedoc on your `typedstruct` definition
153+
""",
154+
Macro.Env.stacktrace(__ENV__)
155+
)
156+
end
157+
end
158+
end
159+
end
160+
123161
@doc false
124162
defmacro __type__(types, opts) do
125163
if Keyword.get(opts, :opaque, false) do
@@ -174,6 +212,7 @@ defmodule TypedStruct do
174212
* `default` - sets the default value for the field
175213
* `enforce` - if set to true, enforces the field and makes its type
176214
non-nullable
215+
* `doc` - description for the field to be added to the `@typedoc`
177216
"""
178217
defmacro field(name, type, opts \\ []) do
179218
quote bind_quoted: [name: name, type: Macro.escape(type), opts: opts] do
@@ -208,6 +247,7 @@ defmodule TypedStruct do
208247
Module.put_attribute(mod, :ts_fields, {name, opts[:default]})
209248
Module.put_attribute(mod, :ts_plugin_fields, {name, type, opts, env})
210249
Module.put_attribute(mod, :ts_types, {name, type_for(type, nullable?)})
250+
Module.put_attribute(mod, :ts_docs, {name, opts[:doc]})
211251
if enforce?, do: Module.put_attribute(mod, :ts_enforce_keys, name)
212252
end
213253

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# SPDX-FileCopyrightText: 2025 Jean-Philippe Cugnet <jean-philippe@cugnet.eu>
2+
# SPDX-License-Identifier: MIT
3+
4+
# NOTE: This file emits a warning when compiled, on purpose. To avoid printing
5+
# this warning, it is compiled from a test with captured I/O.
6+
#
7+
# See test "does not create a `@typedoc` if there is none" for more information.
8+
defmodule TypedStruct.TestStruct.NoTypedoc do
9+
@moduledoc """
10+
A typed struct with documented fields but no `@typedoc`.
11+
"""
12+
use TypedStruct
13+
14+
typedstruct do
15+
field :a_string, String.t(), doc: "just a series of letters"
16+
field :an_int, integer(), doc: "some digits"
17+
end
18+
end

test/support/test_struct.ex

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,31 @@ defmodule TypedStruct.TestStruct do
9393
end
9494
end
9595

96+
defmodule SimpleTypedoc do
97+
@moduledoc """
98+
A typed struct with a `@typedoc`.
99+
"""
100+
use TypedStruct
101+
102+
@typedoc "A typed struct"
103+
typedstruct do
104+
field :field, term()
105+
end
106+
end
107+
108+
defmodule DetailedTypedoc do
109+
@moduledoc """
110+
A typed struct with a `@typedoc`.
111+
"""
112+
use TypedStruct
113+
114+
@typedoc "A typed struct"
115+
typedstruct do
116+
field :a_string, String.t(), doc: "just a series of letters"
117+
field :an_int, integer(), doc: "some digits"
118+
end
119+
end
120+
96121
defmodule Alias do
97122
@moduledoc """
98123
Structs for testing the use of aliases in types.

test/typed_struct_test.exs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,61 @@ defmodule TypedStructTest do
8787
}
8888
end
8989

90+
test "keeps the `@typedoc` if it exists" do
91+
assert extract_t_typedoc(TestStruct.SimpleTypedoc) == %{
92+
"en" => "A typed struct"
93+
}
94+
end
95+
96+
test "adds field descriptions to the `@typedoc` if it exists" do
97+
assert extract_t_typedoc(TestStruct.DetailedTypedoc) == %{
98+
"en" => """
99+
A typed struct
100+
101+
## Fields
102+
103+
- `a_string` - just a series of letters
104+
- `an_int` - some digits
105+
"""
106+
}
107+
end
108+
109+
test "does not create a `@typedoc` if there is none" do
110+
module = TestStruct.NoTypedoc
111+
112+
# HACK: To check the absence of @typedoc with `Code.fetch_docs/1`, we need
113+
# that the module is compiled to a beam file on disk. We could put the
114+
# struct in `test/support/test_struct.ex` along with other test structs,
115+
# however this would emit a warning at compile time, which we want to avoid.
116+
# Let’s then compile the file here while capturing the I/O to suppress the
117+
# warning.
118+
#
119+
# NOTE: The emission of the warning is tested in a following test.
120+
capture_io(:stderr, fn ->
121+
File.rm("_build/test/lib/typed_struct/ebin/#{module}.beam")
122+
Code.put_compiler_option(:docs, true)
123+
[{_, bin}] = Code.compile_file("test/data/test_struct/no_typedoc.ex")
124+
File.write!("_build/test/lib/typed_struct/ebin/#{module}.beam", bin)
125+
end)
126+
127+
assert extract_t_typedoc(module) == :none
128+
end
129+
130+
test "prints a warning if `:doc` is set but there is no `@typedoc`" do
131+
assert capture_io(
132+
:stderr,
133+
fn ->
134+
defmodule FieldDocWithoutTypeDoc do
135+
use TypedStruct
136+
137+
typedstruct do
138+
field :field, term(), doc: "Just a field"
139+
end
140+
end
141+
end
142+
) =~ "adding field documentation has no effect without a @typedoc"
143+
end
144+
90145
############################################################################
91146
## Problems ##
92147
############################################################################
@@ -196,4 +251,17 @@ defmodule TypedStructTest do
196251

197252
defp standardise(list, struct) when is_list(list),
198253
do: Enum.map(list, &standardise(&1, struct))
254+
255+
# Extracts the `@typedoc` for type `t()` in `module`.
256+
defp extract_t_typedoc(module) do
257+
{:docs_v1, _, :elixir, _, _, _,
258+
[
259+
_,
260+
_,
261+
{{:type, :t, _}, _, _, typedoc, _}
262+
]} =
263+
Code.fetch_docs(module)
264+
265+
typedoc
266+
end
199267
end

0 commit comments

Comments
 (0)