diff --git a/.formatter.exs b/.formatter.exs index 29eacdc..8c56ff6 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,7 +1,14 @@ # SPDX-FileCopyrightText: NONE # SPDX-License-Identifier: CC0-1.0 -locals_without_parens = [field: 2, field: 3, plugin: 1, plugin: 2] +locals_without_parens = [ + field: 2, + field: 3, + parameter: 1, + parameter: 2, + plugin: 1, + plugin: 2 +] [ inputs: [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e27865..71eb86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added +* Add support for type parameters (by [@fahchen](https://github.com/fahchen/)). * Add a `:doc` option to `field/3` to add field-specific descriptions in the `@typedoc` (by [@javobalazs](https://github.com/javobalazs)). diff --git a/README.md b/README.md index 50d4ca4..87d7282 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,32 @@ defmodule MyOpaqueStruct do end ``` +You can add type parameters: + +```elixir +defmodule User do + use TypedStruct + + typedstruct do + # Define a type parameter with the `parameter` macro. + parameter :state + + # You can then use it here as `state`. + field :state, state, enforce: true + field :name, String.t() + end +end +``` + +And use them like this: + +```elixir +@type user_state() :: :registered | :confirmed | :logged_in + +@spec get_user_state(User.t(user_state())) :: user_state() +def get_user_state(%User{state, _name}), do: state +``` + If you often define submodules containing only a struct, you can avoid boilerplate code: @@ -203,14 +229,17 @@ typedstruct do end ``` -You can also document individual fields: +You can also document individual type parameters and fields: ```elixir typedstruct do @typedoc "A typed struct" + parameter :state, doc: "type for the `:state` field" + field :a_string, String.t(), doc: "just a series of letters" field :an_int, integer(), doc: "some explanation" + field :state, state, doc: "the current state" end ``` @@ -220,6 +249,10 @@ This generate the following `@typedoc`: @typedoc """ A typed struct +## Type parameters + +- `state` - type for the `:state` field + ## Fields - `a_string` - just a series of letters @@ -380,10 +413,29 @@ generates the following type: ```elixir @opaque t() :: %__MODULE__{ - name: String.t() + name: String.t() | nil } ``` +When you specify parameters with the `parameter/1` macro, they are used in the +definition of the type: + +```elixir +typedstruct do + parameter :param + + field :field, param +end +``` + +gives the following type: + +```elixir +@type t(param) :: %__MODULE__{ + field: param | nil + } +``` + When passing `module: ModuleName`, the whole `typedstruct` block is wrapped in a module definition. This way, the following definition: diff --git a/lib/typed_struct.ex b/lib/typed_struct.ex index 3a5158e..0440f7f 100644 --- a/lib/typed_struct.ex +++ b/lib/typed_struct.ex @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: 2018 Marcin Górnik # SPDX-FileCopyrightText: 2022 Jonathan Chukinas # SPDX-FileCopyrightText: 2022 Balázs Jávorszky +# SPDX-FileCopyrightText: 2022 Phil Chen <06fahchen@gmail.com> # # SPDX-License-Identifier: MIT @@ -15,9 +16,11 @@ defmodule TypedStruct do @accumulating_attrs [ :ts_plugins, :ts_plugin_fields, + :ts_parameters, + :ts_parameter_docs, :ts_fields, :ts_types, - :ts_docs, + :ts_field_docs, :ts_enforce_keys ] @@ -70,6 +73,18 @@ defmodule TypedStruct do end end + You can also add type parameters: + + defmodule MyModule do + use TypedStruct + + typedstruct do + parameter :type + + field :field, type + end + end + You can create the struct in a submodule instead: defmodule MyModule do @@ -118,36 +133,48 @@ defmodule TypedStruct do @enforce_keys @ts_enforce_keys defstruct @ts_fields - TypedStruct.__typedoc__(@ts_docs) - TypedStruct.__type__(@ts_types, unquote(opts)) + TypedStruct.__typedoc__(@ts_parameter_docs, @ts_field_docs) + + TypedStruct.__type__( + Enum.reverse(@ts_parameters), + @ts_types, + unquote(opts) + ) end end @doc false - defmacro __typedoc__(docs) do - quote bind_quoted: [docs: docs] do + defmacro __typedoc__(parameter_docs, field_docs) do + quote bind_quoted: [ + parameter_docs: parameter_docs, + field_docs: field_docs + ] do + parameter_docs = + parameter_docs + |> Enum.reverse() + |> Enum.filter(fn {_, doc} -> !is_nil(doc) end) + |> Enum.map_join("\n", fn {name, doc} -> "- `#{name}` - #{doc}" end) + |> TypedStruct.__add_heading__("Type parameters") + field_docs = - docs + field_docs |> Enum.reverse() |> Enum.filter(fn {_, doc} -> !is_nil(doc) end) - |> Enum.map(fn {name, doc} -> "- `#{name}` - #{doc}" end) + |> Enum.map_join("\n", fn {name, doc} -> "- `#{name}` - #{doc}" end) + |> TypedStruct.__add_heading__("Fields") - if field_docs != [] do + if parameter_docs != "" || field_docs != "" do # If there are field docs, we complete the `@typedoc` with field # documentation. However, if there is no `@typedoc` already, let’s emit # a warning instead. if Module.has_attribute?(__MODULE__, :typedoc) do @typedoc """ - #{@typedoc} - - ## Fields - - #{Enum.join(field_docs, "\n")} + #{@typedoc}#{parameter_docs}#{field_docs} """ else IO.warn( """ - adding field documentation has no effect without a @typedoc + adding parameter or field documentation has no effect without a @typedoc hint: add a @typedoc on your `typedstruct` definition """, @@ -159,14 +186,22 @@ defmodule TypedStruct do end @doc false - defmacro __type__(types, opts) do + def __add_heading__("", _heading), do: "" + def __add_heading__(doc, heading), do: "\n\n## #{heading}\n\n#{doc}" + + @doc false + defmacro __type__(parameters, types, opts) do if Keyword.get(opts, :opaque, false) do - quote bind_quoted: [types: types] do - @opaque t() :: %__MODULE__{unquote_splicing(types)} + quote bind_quoted: [parameters: parameters, types: types] do + @opaque t(unquote_splicing(parameters)) :: %__MODULE__{ + unquote_splicing(types) + } end else - quote bind_quoted: [types: types] do - @type t() :: %__MODULE__{unquote_splicing(types)} + quote bind_quoted: [parameters: parameters, types: types] do + @type t(unquote_splicing(parameters)) :: %__MODULE__{ + unquote_splicing(types) + } end end end @@ -199,6 +234,36 @@ defmodule TypedStruct do end end + @doc """ + Defines a type parameter for the currently defined struct. + + ## Example + + typedstruct do + # Defines a type parameter named `type_param` + parameter :type_param + + # The type parameter can be used as a type in the `field` macro. + field :a_field, type_param + end + """ + defmacro parameter(name, opts \\ []) do + quote bind_quoted: [name: name, opts: opts] do + TypedStruct.__parameter__(name, opts, __ENV__) + end + end + + @doc false + def __parameter__(name, opts, %Macro.Env{module: mod}) when is_atom(name) do + Module.put_attribute(mod, :ts_parameters, Macro.var(name, mod)) + Module.put_attribute(mod, :ts_parameter_docs, {name, opts[:doc]}) + end + + def __parameter__(name, _opts, _env) do + raise ArgumentError, + "the name of a type parameter must be an atom, got #{inspect(name)}" + end + @doc """ Defines a field in a typed struct. @@ -247,14 +312,14 @@ defmodule TypedStruct do Module.put_attribute(mod, :ts_fields, {name, opts[:default]}) Module.put_attribute(mod, :ts_plugin_fields, {name, type, opts, env}) Module.put_attribute(mod, :ts_types, {name, type_for(type, nullable?)}) - Module.put_attribute(mod, :ts_docs, {name, opts[:doc]}) + Module.put_attribute(mod, :ts_field_docs, {name, opts[:doc]}) if enforce?, do: Module.put_attribute(mod, :ts_enforce_keys, name) end # Checks whether some value looks like Elixir AST. defp ast?({name, meta, params}) when (is_atom(name) or is_tuple(name)) and is_list(meta) and - is_list(params), + (is_list(params) or is_nil(params)), do: true defp ast?(_), do: false diff --git a/test/support/test_struct.ex b/test/support/test_struct.ex index a034eaf..1d11809 100644 --- a/test/support/test_struct.ex +++ b/test/support/test_struct.ex @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2018, 2020, 2025 Jean-Philippe Cugnet # SPDX-FileCopyrightText: 2018 Marcin Górnik +# SPDX-FileCopyrightText: 2022 Phil Chen <06fahchen@gmail.com> # SPDX-FileCopyrightText: 2023 Serge Aleynikov # # SPDX-License-Identifier: MIT @@ -82,6 +83,37 @@ defmodule TypedStruct.TestStruct do end end + defmodule WithParameter do + @moduledoc """ + A struct with a parameterised type. + """ + use TypedStruct + + typedstruct do + parameter :t1 + parameter :t2 + + field :field_t1, t1 + field :field_t2, t2 + field :enforced_field_t1, t1, enforce: true + end + + defmodule Expected do + @moduledoc """ + `WithParameter` but defined manually. + """ + + @enforce_keys [:enforced_field_t1] + defstruct [:field_t1, :field_t2, :enforced_field_t1] + + @type t(t1, t2) :: %__MODULE__{ + field_t1: t1 | nil, + field_t2: t2 | nil, + enforced_field_t1: t1 + } + end + end + defmodule AsSubmodule do @moduledoc """ A struct defined as a submodule. @@ -105,9 +137,25 @@ defmodule TypedStruct.TestStruct do end end - defmodule DetailedTypedoc do + defmodule ParametersTypedoc do @moduledoc """ - A typed struct with a `@typedoc`. + A typed struct with a `@typedoc` and type parameter docs. + """ + use TypedStruct + + @typedoc "A typed struct" + typedstruct do + parameter :string, doc: "the string type of your choice" + parameter :int, doc: "the integer type of your choice" + + field :a_string, string + field :an_int, int + end + end + + defmodule FieldsTypedoc do + @moduledoc """ + A typed struct with a `@typedoc` and field docs. """ use TypedStruct @@ -118,6 +166,22 @@ defmodule TypedStruct.TestStruct do end end + defmodule ParametersAndFieldsTypedoc do + @moduledoc """ + A typed struct with a `@typedoc` and both parameter and field docs + """ + use TypedStruct + + @typedoc "A typed struct" + typedstruct do + parameter :string, doc: "the string type of your choice" + parameter :int, doc: "the integer type of your choice" + + field :a_string, string, doc: "just a series of letters" + field :an_int, int, doc: "some digits" + end + end + defmodule Alias do @moduledoc """ Structs for testing the use of aliases in types. diff --git a/test/typed_struct_test.exs b/test/typed_struct_test.exs index 2ec37aa..446c3e5 100644 --- a/test/typed_struct_test.exs +++ b/test/typed_struct_test.exs @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2018, 2020, 2022, 2025 Jean-Philippe Cugnet # SPDX-FileCopyrightText: 2018 Marcin Górnik +# SPDX-FileCopyrightText: 2022 Phil Chen <06fahchen@gmail.com> # # SPDX-License-Identifier: MIT @@ -79,6 +80,22 @@ defmodule TypedStructTest do assert type1 == type2 end + test "generates a parameterized type for the struct" do + # Get both types and standardise them (remove line numbers and rename + # the second struct with the name of the first one). + type1 = + TestStruct.WithParameter + |> extract_first_type() + |> standardise(TestStruct.WithParameter) + + type2 = + TestStruct.WithParameter.Expected + |> extract_first_type() + |> standardise(TestStruct.WithParameter.Expected) + + assert type1 == type2 + end + test "generates the struct in a submodule if `module: ModuleName` is set" do # credo:disable-for-next-line Credo.Check.Design.AliasUsage assert TestStruct.AsSubmodule.Struct.__struct__() == @@ -93,11 +110,42 @@ defmodule TypedStructTest do } end + test "adds type parameter descriptions to the `@typedoc` if it exists" do + assert extract_t_typedoc(TestStruct.ParametersTypedoc) == %{ + "en" => """ + A typed struct + + ## Type parameters + + - `string` - the string type of your choice + - `int` - the integer type of your choice + """ + } + end + test "adds field descriptions to the `@typedoc` if it exists" do - assert extract_t_typedoc(TestStruct.DetailedTypedoc) == %{ + assert extract_t_typedoc(TestStruct.FieldsTypedoc) == %{ + "en" => """ + A typed struct + + ## Fields + + - `a_string` - just a series of letters + - `an_int` - some digits + """ + } + end + + test "adds both parameter and field descriptions to the `@typedoc`" do + assert extract_t_typedoc(TestStruct.ParametersAndFieldsTypedoc) == %{ "en" => """ A typed struct + ## Type parameters + + - `string` - the string type of your choice + - `int` - the integer type of your choice + ## Fields - `a_string` - just a series of letters @@ -127,7 +175,24 @@ defmodule TypedStructTest do assert extract_t_typedoc(module) == :none end - test "prints a warning if `:doc` is set but there is no `@typedoc`" do + test "prints a warning if `:doc` is set on a parameter but there is no `@typedoc`" do + assert capture_io( + :stderr, + fn -> + defmodule ParameterDocWithoutTypeDoc do + use TypedStruct + + typedstruct do + parameter :type, doc: "the type of the field" + field :field, type + end + end + end + ) =~ + "adding parameter or field documentation has no effect without a @typedoc" + end + + test "prints a warning if `:doc` is set on a field but there is no `@typedoc`" do assert capture_io( :stderr, fn -> @@ -135,11 +200,12 @@ defmodule TypedStructTest do use TypedStruct typedstruct do - field :field, term(), doc: "Just a field" + field :field, term(), doc: "just a field" end end end - ) =~ "adding field documentation has no effect without a @typedoc" + ) =~ + "adding parameter or field documentation has no effect without a @typedoc" end ############################################################################ @@ -163,6 +229,21 @@ defmodule TypedStructTest do end) =~ "undefined function field/2" end + test "the name of a type parameter must be an atom" do + assert_raise ArgumentError, + "the name of a type parameter must be an atom, got 3", + fn -> + defmodule InvalidStruct do + use TypedStruct + + typedstruct do + parameter 3 + field :field, term() + end + end + end + end + test "the name of a field must be an atom" do assert_raise ArgumentError, "a field name must be an atom, got 3", fn -> defmodule InvalidStruct do @@ -237,14 +318,20 @@ defmodule TypedStructTest do defp standardise({:type, _, type, params}, struct), do: {:type, :line, type, standardise(params, struct)} + defp standardise({:user_type, _, type, params}, struct), + do: {:user_type, :line, type, standardise(params, struct)} + defp standardise({:remote_type, _, params}, struct), do: {:remote_type, :line, standardise(params, struct)} defp standardise({:atom, _, struct}, struct), do: {:atom, :line, TestStruct} + defp standardise({:var, _, name}, _), + do: {:var, :line, name} + defp standardise({name, type, params}, struct) when is_tuple(type), - do: {name, standardise(type, struct), params} + do: {name, standardise(type, struct), standardise(params, struct)} defp standardise({type, _, literal}, _struct), do: {type, :line, literal}