├── .formatter.exs ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── guides ├── enum_types.md ├── fast_vs_slow_access.md ├── integer_based_enum.md ├── introspection.md └── string_based_enum.md ├── lib └── simple_enum.ex ├── mix.exs ├── mix.lock └── test ├── fixtures.exs ├── simple_enum_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | 3 | locals_without_parens = [defenum: 2] 4 | 5 | [ 6 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 7 | locals_without_parens: locals_without_parens, 8 | export: [locals_without_parens: locals_without_parens] 9 | ] 10 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tests 3 | 4 | on: [push, pull_request] 5 | 6 | jobs: 7 | tests: 8 | name: Run Tests (Elixir ${{ matrix.combo.elixir }} - OTP ${{ matrix.combo.otp }}) 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | combo: 13 | - elixir: 'v1.10' 14 | otp: '23' 15 | - elixir: 'v1.11' 16 | otp: '23' 17 | - elixir: 'v1.12' 18 | otp: '23' 19 | - elixir: 'v1.13' 20 | otp: '24' 21 | env: 22 | MIX_ENV: test 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: erlef/setup-beam@v1 26 | with: 27 | otp-version: ${{ matrix.combo.otp }} 28 | elixir-version: ${{ matrix.combo.elixir }} 29 | - uses: actions/cache@v2 30 | with: 31 | path: deps 32 | key: ${{ runner.os }}-${{ matrix.combo.otp }}-${{ matrix.combo.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 33 | restore-keys: | 34 | ${{ runner.os }}-${{ matrix.combo.otp }}-${{ matrix.combo.elixir }}- 35 | - run: mix deps.get 36 | - run: mix format --dry-run --check-formatted 37 | - run: mix test --trace 38 | - run: mix coveralls.github 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | simple_enum-*.tar 24 | 25 | # Ignore some local files 26 | /TODOLIST.md 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.1.0 (2021-12-26) 4 | 5 | * Initial release 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2021 DarkyZ aka NotAVirus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleEnum 2 | 3 | [![Hex.pm version](https://img.shields.io/hexpm/v/simple_enum.svg?style=flat)](https://hex.pm/packages/simple_enum) 4 | [![Hex.pm license](https://img.shields.io/hexpm/l/simple_enum.svg?style=flat)](https://hex.pm/packages/simple_enum) 5 | [![Build Status](https://github.com/ImNotAVirus/simple_enum/actions/workflows/tests.yml/badge.svg)](https://github.com/ImNotAVirus/simple_enum/actions/workflows/tests.yml) 6 | [![Coverage Status](https://coveralls.io/repos/github/ImNotAVirus/simple_enum/badge.svg?branch=master)](https://coveralls.io/github/ImNotAVirus/simple_enum?branch=master) 7 | 8 | 9 | 10 | SimpleEnum is a simple library that implements Enumerations in Elixir. 11 | 12 | An Enumeration is a user-defined type that consists of a set of several named 13 | constants that are known as Enumerators. 14 | The purpose of SimpleEnum is to provide an equivalent for the Elixir language. 15 | 16 | SimpleEnum is: 17 | 18 | - **fast**: being based on a macro system, access to the Enum will be resolved 19 | at compile time when it is possible (see. [Fast vs Slow access](guides/fast_vs_slow_access.md)) 20 | - **simple**: the use of the library has been designed to be as simple as possible 21 | for a developer to use. In addition to providing Enums, it automatically defines their 22 | [types](guides/enum_types.md) and provides an [introspection system](guides/introspection.md). 23 | 24 | ## Installation 25 | 26 | The package can be installed by adding `simple_enum` to your list of dependencies 27 | in `mix.exs`: 28 | 29 | ```elixir 30 | # my_app/mix.exs 31 | def deps do 32 | [ 33 | {:simple_enum, "~> 0.1"} 34 | ] 35 | end 36 | ``` 37 | 38 | Then, update your dependencies: 39 | 40 | ```sh-session 41 | $ mix deps.get 42 | ``` 43 | 44 | Optionally, if you use the formatter, add this line to `.formatter.exs`: 45 | 46 | ```elixir 47 | # my_app/.formatter.exs 48 | [ 49 | import_deps: [:simple_enum] 50 | ] 51 | ``` 52 | 53 | ## Basic Usage 54 | 55 | ``` elixir 56 | iex> defmodule MyEnums do 57 | ...> import SimpleEnum, only: [defenum: 2] 58 | ...> 59 | ...> defenum :color, [:blue, :green, :red] 60 | ...> defenum :day, monday: "MON", tuesday: "TUE", wednesday: "WED" 61 | ...> end 62 | 63 | iex> require MyEnums 64 | 65 | iex> MyEnums.color(:blue) 66 | 0 67 | iex> MyEnums.color(0) 68 | :blue 69 | iex> MyEnums.day(:monday) 70 | "MON" 71 | iex> MyEnums.day("MON") 72 | :monday 73 | iex> MyEnums.day("MONN") 74 | ** (ArgumentError) invalid value "MONN" for Enum MyEnums.day/1. Expected one of [:monday, :tuesday, :wednesday, "MON", "TUE", "WED"] 75 | ``` 76 | 77 | 78 | 79 | Full documentation can be found at [https://hexdocs.pm/simple_enum](https://hexdocs.pm/simple_enum). 80 | 81 | ## Copyright and License 82 | 83 | Copyright (c) 2021 DarkyZ aka NotAVirus. 84 | 85 | SimpleEnum source code is licensed under the [MIT License](LICENSE.md). 86 | -------------------------------------------------------------------------------- /guides/enum_types.md: -------------------------------------------------------------------------------- 1 | # Enum types 2 | 3 | Enums automatically generate typespecs to be used with Dialyzer. 4 | 5 | ## Example 6 | 7 | # lib/my_app/enums.ex 8 | defmodule MyApp.Enums do 9 | import SimpleEnum, only: [defenum: 2] 10 | 11 | defenum :color, [:blue, :green, :red] 12 | defenum :state, [{:active, 1}, :inactive, {:unknown, -1}, :default] 13 | defenum :day, monday: "MON", tuesday: "TUE", wednesday: "WED" 14 | end 15 | 16 | iex> t(MyApp.Enums) 17 | @type color() :: :blue | :green | :red | 0 | 1 | 2 18 | @type color_keys() :: :blue | :green | :red 19 | @type color_values() :: 0 | 1 | 2 20 | @type day() :: :monday | :tuesday | :wednesday | String.t() 21 | @type day_keys() :: :monday | :tuesday | :wednesday 22 | @type day_values() :: String.t() 23 | @type state() :: :active | :inactive | :unknown | :default | 1 | 2 | -1 | 0 24 | @type state_keys() :: :active | :inactive | :unknown | :default 25 | @type state_values() :: 1 | 2 | -1 | 0 26 | 27 | **NOTE**: As you can see, in the case of a string-based Enum, the type generated 28 | for a value's typespec will be `t:String.t/0` since it is not possible to specify a 29 | String set in typespecs. 30 | -------------------------------------------------------------------------------- /guides/fast_vs_slow_access.md: -------------------------------------------------------------------------------- 1 | # Fast vs Slow access 2 | 3 | Enumerations access can be resolved in 2 different ways: 4 | 5 | - At the compilation (also called **Fast access**) 6 | - At the runtime (also called **Slow access**) 7 | 8 | ## Fast access 9 | 10 | When it's possible, SimpleEnum will try to replace Enum access directly 11 | with it's value (Fast access). 12 | In order to do this, all arguments given to the Enum access must be resolvable 13 | at compile time. 14 | 15 | To verify this behaviour, let's write a simple function to inspect the AST of an 16 | Enum named `color`. 17 | 18 | iex> defmodule EnumInspector do 19 | ...> def inspect_ast(quoted_expr, env) do 20 | ...> quoted_expr 21 | ...> |> Macro.postwalk(&expand_enum_ast(&1, env)) 22 | ...> |> Macro.to_string() 23 | ...> |> IO.puts 24 | ...> end 25 | ...> 26 | ...> # Expand only the Enum's AST 27 | ...> defp expand_enum_ast({:color, [], _} = x, env), do: Macro.expand(x, env) 28 | ...> defp expand_enum_ast(x, _), do: x 29 | ...> end 30 | 31 | Now, let's use this module and see how Elixir compile an Enum access with known 32 | arguments at compile time : 33 | 34 | iex> defmodule MyApp.Enums do 35 | ...> import SimpleEnum, only: [defenum: 2] 36 | ...> 37 | ...> # Define the Enum 38 | ...> defenum :color, [:blue, :green, :red] 39 | ...> 40 | ...> # Inspect the generated AST of a function using Fast access 41 | ...> EnumInspector.inspect_ast(quote do 42 | ...> dep test() do 43 | ...> color(:red, :value) 44 | ...> end 45 | ...> end, __ENV__) 46 | ...> end 47 | 48 | dep(test()) do 49 | 2 50 | end 51 | 52 | We notice that the Enum access `color(:red, :value)` has been automatically replaced 53 | with `2` by the compiler. 54 | 55 | This explain why Fast access can be used in guards for example. 56 | 57 | **NOTE**: [Module attributes](https://elixir-lang.org/getting-started/module-attributes.html#as-constants) 58 | are also supported with fast access. 59 | 60 | ## Slow access 61 | 62 | Now let's take a look at what happens when the values cannot be determined at compile 63 | time. 64 | 65 | iex> defmodule MyApp.Enums do 66 | ...> import SimpleEnum, only: [defenum: 2] 67 | ...> 68 | ...> # Define the Enum 69 | ...> defenum :color, [:blue, :green, :red] 70 | ...> 71 | ...> # Inspect the generated AST of a function using Slow access 72 | ...> EnumInspector.inspect_ast(quote do 73 | ...> dep test(value) do 74 | ...> color(value, :value) 75 | ...> end 76 | ...> end, __ENV__) 77 | ...> end 78 | 79 | dep(test(value)) do 80 | case({value, :value}) do 81 | {x, :key} when x in [:blue, :green, :red] -> 82 | x 83 | {x, :value} when x in [0, 1, 2] -> 84 | x 85 | {x, :key} when x in [0, 1, 2] -> 86 | Map.fetch!(%{0 => :blue, 1 => :green, 2 => :red}, x) 87 | {x, :value} when x in [:blue, :green, :red] -> 88 | Keyword.fetch!([blue: 0, green: 1, red: 2], x) 89 | {x, :tuple} when x in [:blue, :green, :red] -> 90 | {x, Keyword.fetch!([blue: 0, green: 1, red: 2], x)} 91 | {x, :tuple} when x in [0, 1, 2] -> 92 | {Map.fetch!(%{0 => :blue, 1 => :green, 2 => :red}, x), x} 93 | {_, t} when t not in [:key, :value, :tuple] -> 94 | raise(ArgumentError, "invalid type :value. Expected one of [:key, :value, :tuple]") 95 | {x, _} -> 96 | raise(ArgumentError, "invalid value {:value, [], MyApp.Enums} for Enum MyApp.Enums.color/2. Expected one of [:blue, :green, :red, 0, 1, 2]\n") 97 | end 98 | end 99 | 100 | This time, the `color(value, :value)` access has been transformed into a big `case` 101 | checking at runtime the value of the arguments given. 102 | 103 | _It is therefore recommended, when it is possible, to avoid creating temporary variables 104 | in order to store arguments for an Enum access._ 105 | -------------------------------------------------------------------------------- /guides/integer_based_enum.md: -------------------------------------------------------------------------------- 1 | # Integer-based Enum 2 | 3 | There are 2 ways to define an enumerator for integer-based Enumerations: 4 | 5 | - Without specifying a default value 6 | - With a defaut value 7 | 8 | ## Without default value 9 | 10 | If the first enumerator does not have an initializer, the associated value 11 | is zero. For any other enumerator whose definition does not have an 12 | initializer, the associated value is the value of the previous enumerator 13 | plus one. 14 | 15 | ...> 16 | iex> defenum :color, [:blue, :green, :red] 17 | ...> 18 | iex> color(:blue, :value) 19 | 0 20 | iex> color(:green, :value) 21 | 1 22 | iex> color(:red, :value) 23 | 2 24 | 25 | ## With default value 26 | 27 | When enumerators have values assigned SimpleEnum will simply use them. 28 | 29 | ...> 30 | iex> defenum :color, [blue: 1, green: 2, red: 4] 31 | ...> 32 | iex> color(:blue, :value) 33 | 1 34 | iex> color(:green, :value) 35 | 2 36 | iex> color(:red, :value) 37 | 4 38 | 39 | It is of course possible to mix both ways: 40 | 41 | ...> 42 | iex> defenum :role, [:default, {:support, 10}, :moderator, {:admin, 999}] 43 | ...> 44 | iex> role(:default, :value) 45 | 0 46 | iex> role(:support, :value) 47 | 10 48 | iex> role(:moderator, :value) 49 | 11 50 | iex> role(:admin, :value) 51 | 999 52 | 53 | For more details about macros generated by `SimpleEnum.defenum/2`, feels free 54 | to check it's documentation. 55 | 56 | ## Example 57 | 58 | Let's see a more complete example. 59 | 60 | # lib/my_app/account.ex 61 | defmodule MyApp.Account do 62 | import SimpleEnum, only: [defenum: 2] 63 | 64 | # Define Integer-based Enumeration 65 | defenum :type, [:user, :admin, banned: -1] 66 | 67 | defstruct [:type, :username] 68 | 69 | @type t :: %__MODULE__{ 70 | # We want only keys in our structs... 71 | type: type_keys(), 72 | username: String.t() 73 | } 74 | 75 | def cast(%{type: type_value, username: username}) do 76 | %__MODULE__{ 77 | # ... so let's lookup only the key 78 | type: type(type_value, :key), 79 | username: username 80 | } 81 | end 82 | end 83 | 84 | iex> MyApp.Account.cast(%{type: :user, username: "John Doe"}) 85 | %MyApp.Account{type: :user, username: "John Doe"} 86 | 87 | iex> MyApp.Account.cast(%{type: 0, username: "John Doe"}) 88 | %MyApp.Account{type: :user, username: "John Doe"} 89 | 90 | iex> MyApp.Account.cast(%{type: 1, username: "John Admin"}) 91 | %MyApp.Account{type: :admin, username: "John Admin"} 92 | 93 | iex> MyApp.Account.cast(%{type: -1, username: "John Banned"}) 94 | %MyApp.Account{type: :banned, username: "John Banned"} 95 | 96 | iex> MyApp.Account.cast(%{type: 999, username: "John Invalid"}) 97 | ** (ArgumentError) invalid value 999 for Enum MyApp.Account.type/2. Expected one of [:user, :admin, :banned, 0, 1, -1] 98 | -------------------------------------------------------------------------------- /guides/introspection.md: -------------------------------------------------------------------------------- 1 | # Introspection 2 | 3 | The created Enums will also have introspection helpers in order 4 | to inspect them at compile time or at runtime. 5 | 6 | ## Examples 7 | 8 | iex> MyEnums.color(:__keys__) 9 | [:blue, :green, :red] 10 | iex> MyEnums.color(:__values__) 11 | [0, 1, 2] 12 | iex> MyEnums.color(:__enumerators__) 13 | [blue: 0, green: 1, red: 2] 14 | 15 | Being macros, introspection helpers can be used in guards as 16 | well as to connect SimpleEnum to other libraries/frameworks. 17 | 18 | ## Example with guards 19 | 20 | iex> defmodule MyEnums do 21 | ...> import SimpleEnum, only: [defenum: 2] 22 | ...> 23 | ...> defenum :color, [:blue, :green, :red] 24 | ...> 25 | ...> defguard is_color(value) when value in color(:__keys__) or value in color(:__values__) 26 | ...> defguard is_color_key(value) when value in color(:__keys__) 27 | ...> defguard is_color_value(value) when value in color(:__values__) 28 | ...> end 29 | 30 | iex> MyEnums.is_color(0) 31 | true 32 | iex> MyEnums.is_color(:red) 33 | true 34 | iex> MyEnums.is_color(:human) 35 | false 36 | iex> MyEnums.is_color_key(0) 37 | false 38 | iex> MyEnums.is_color_key(:red) 39 | true 40 | iex> MyEnums.is_color_value(0) 41 | true 42 | iex> MyEnums.is_color_value(:red) 43 | false 44 | 45 | ## Example with Ecto 46 | 47 | # lib/my_app/accounts/user_enums.ex 48 | defmodule MyApp.Accounts.UserEnums do 49 | import SimpleEnum, only: [defenum: 2] 50 | defenum :user_role, [:admin, :moderator, :seller, :buyer] 51 | end 52 | 53 | # lib/my_app/accounts/user_role.ex 54 | defmodule MyApp.Accounts.UserRole do 55 | require MyApp.Accounts.UserEnums 56 | alias MyApp.Accounts.UserEnums 57 | 58 | use EctoEnum, type: :user_role, enums: UserEnums.user_role(:__enumerators__) 59 | end 60 | -------------------------------------------------------------------------------- /guides/string_based_enum.md: -------------------------------------------------------------------------------- 1 | # String-based Enum 2 | 3 | To define a string-based Enumeration, you can just use a keyword of strings 4 | as the enumerator-list. 5 | 6 | **NOTE**: For a string-based Enumeration, every enumerator must have an 7 | initializer. 8 | 9 | For more details about macros generated by `SimpleEnum.defenum/2`, feels free 10 | to check it's documentation. 11 | 12 | ## Example 13 | 14 | # lib/my_app/person.ex 15 | defmodule MyApp.Person do 16 | import SimpleEnum, only: [defenum: 2] 17 | 18 | # Define String-based Enumeration 19 | defenum :gender, man: "M", woman: "W", unspecified: "U" 20 | 21 | defstruct [:gender, :first_name, :last_name] 22 | 23 | @type t :: %__MODULE__{ 24 | # We want only keys in our structs... 25 | gender: gender_keys(), 26 | first_name: String.t(), 27 | last_name: String.t() 28 | } 29 | 30 | def cast(%{gender: gender_value, first_name: first_name, last_name: last_name}) do 31 | %__MODULE__{ 32 | # ... so let's lookup only the key 33 | gender: gender(gender_value, :key), 34 | first_name: first_name, 35 | last_name: last_name 36 | } 37 | end 38 | end 39 | 40 | iex> MyApp.Person.cast(%{gender: :man, first_name: "John", last_name: "Doe"}) 41 | %MyApp.Person{first_name: "John", gender: :man, last_name: "Doe"} 42 | 43 | iex> MyApp.Person.cast(%{gender: "M", first_name: "John", last_name: "Doe"}) 44 | %MyApp.Person{first_name: "John", gender: :man, last_name: "Doe"} 45 | 46 | iex> MyApp.Person.cast(%{gender: "W", first_name: "Jane", last_name: "Doe"}) 47 | %MyApp.Person{first_name: "Jane", gender: :woman, last_name: "Doe"} 48 | 49 | iex> MyApp.Person.cast(%{gender: "U", first_name: "", last_name: ""}) 50 | %MyApp.Person{first_name: "", gender: :unspecified, last_name: ""} 51 | 52 | iex> MyApp.Person.cast(%{gender: "?", first_name: "", last_name: ""}) 53 | ** (ArgumentError) invalid value "?" for Enum MyApp.Person.gender/2. Expected one of [:man, :woman, :unspecified, "M", "W", "U"] 54 | -------------------------------------------------------------------------------- /lib/simple_enum.ex: -------------------------------------------------------------------------------- 1 | defmodule SimpleEnum do 2 | readme_path = [__DIR__, "..", "README.md"] |> Path.join() |> Path.expand() 3 | 4 | @external_resource readme_path 5 | @moduledoc readme_path 6 | |> File.read!() 7 | |> String.split("") 8 | |> Enum.fetch!(1) 9 | 10 | ## Public API 11 | 12 | @doc ~S""" 13 | Defines a set of macros to create and access Enumerations. 14 | 15 | The name of the generated macros and types will be `name` (which has to be an atom). 16 | The `enumerators` argument has to be either: 17 | 18 | * A keyword list composed of strings (to create a string-based Enumeration) 19 | * A keyword list composed of integers (to create an integer-based Enumeration) 20 | * A list of atoms (to create an integer-based Enumeration) 21 | 22 | For more details about [string-based Enumeration](guides/string_based_enum.md) and 23 | [integer-based Enumeration](guides/integer_based_enum.md), you can check the 24 | corresponding guide. 25 | 26 | The following macros are generated: 27 | 28 | * `name/1` to access a key, a value or to inspect an Enumeration 29 | * `name/2` to access a key, a value or its tuple by specifying the return type 30 | 31 | The following types are generated: 32 | 33 | * `@type enum :: :key1 | :key2 | :value1 | :value2` 34 | * `@type enum_keys :: :key1 | :key2` 35 | * `@type enum_values :: :value1 | :value2` 36 | 37 | For more details about [types](guides/enum_types.md) you can also check the 38 | corresponding guide. 39 | 40 | All these macros are public macros (as defined by `defmacro/2`). 41 | 42 | See the "Examples" section for examples on how to use these macros. 43 | 44 | ## Examples 45 | 46 | defmodule MyApp.Enums do 47 | import SimpleEnum, only: [defenum: 2] 48 | defenum :color, [:blue, :green, :red] 49 | end 50 | 51 | In the example above, a set of macros named `color` but with different arities 52 | will be defined to manipulate the underlying Enumeration. 53 | 54 | # Import the module to make the color macros locally available 55 | import MyApp.Enums 56 | 57 | # To lookup the corresponding value 58 | color(:blue) #=> 0 59 | color(:green) #=> 1 60 | color(:red) #=> 2 61 | color(0) #=> :blue 62 | color(1) #=> :green 63 | color(2) #=> :red 64 | 65 | # To lookup for the key regardless of the given value 66 | color(:red, :key) #=> :red 67 | color(2, :key) #=> :red 68 | 69 | # To lookup for the value regardless of the given value 70 | color(:red, :value) #=> 2 71 | color(2, :value) #=> 2 72 | 73 | # To get the key/value pair of the given value 74 | color(:red, :tuple) #=> {:red, 2} 75 | color(2, :tuple) #=> {:red, 2} 76 | 77 | Is also possible to inspect the Enumeration by using introspection helpers : 78 | 79 | color(:__keys__) #=> [:blue, :green, :red] 80 | color(:__values__) #=> [0, 1, 2] 81 | color(:__enumerators__) #=> [blue: 0, green: 1, red: 2] 82 | 83 | """ 84 | defmacro defenum(name, enumerators) do 85 | expanded_name = Macro.expand(name, __CALLER__) 86 | expanded_kv = Macro.prewalk(enumerators, &Macro.expand(&1, __CALLER__)) 87 | enum_name = "#{inspect(__CALLER__.module)}.#{expanded_name}" 88 | fields = kv_to_fields(expanded_kv, enum_name, __CALLER__) 89 | keys = Keyword.keys(fields) 90 | values = Keyword.values(fields) 91 | 92 | raise_if_duplicate!("key", keys, enum_name, __CALLER__) 93 | raise_if_duplicate!("value", values, enum_name, __CALLER__) 94 | 95 | quote location: :keep do 96 | @name unquote(expanded_name) 97 | @enum_name unquote(enum_name) 98 | @fields unquote(fields) 99 | @keys unquote(keys) 100 | @values unquote(values) 101 | @types [:key, :value, :tuple] 102 | 103 | @fields_rev @fields 104 | |> Enum.map(fn {k, v} -> {v, k} end) 105 | |> Enum.into(%{}) 106 | |> Macro.escape() 107 | 108 | unquote(types()) 109 | unquote(def_fast_arity_1()) 110 | unquote(def_fast_arity_2()) 111 | 112 | # Maybe if would be better to use __before_compile__ to append these functions? 113 | if not Module.defines?(__MODULE__, {:slow_arity_1, 4}) do 114 | unquote(def_slow_arity_1()) 115 | unquote(def_slow_arity_2()) 116 | end 117 | end 118 | end 119 | 120 | ## Define helpers 121 | 122 | defp types() do 123 | quote unquote: false, location: :keep do 124 | keys_ast = @keys |> Enum.reverse() |> Enum.reduce(&{:|, [], [&1, &2]}) 125 | last_key = Enum.at(@keys, -1) 126 | 127 | # @type name_keys :: :key1 | :key2 | :key3 128 | @type unquote(Macro.var(:"#{@name}_keys", __MODULE__)) :: unquote(keys_ast) 129 | 130 | if @values |> Enum.at(0) |> is_binary() do 131 | # @type name_values :: String.t() 132 | @type unquote(Macro.var(:"#{@name}_values", __MODULE__)) :: String.t() 133 | 134 | string_t_ast = {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []} 135 | 136 | # @type name :: :key1 | :key2 | :key3 | String.t() 137 | @type unquote(Macro.var(@name, __MODULE__)) :: 138 | unquote( 139 | Macro.postwalk(keys_ast, fn 140 | ^last_key = x -> {:|, [], [x, string_t_ast]} 141 | x -> x 142 | end) 143 | ) 144 | else 145 | values_ast = @values |> Enum.reverse() |> Enum.reduce(&{:|, [], [&1, &2]}) 146 | 147 | # @type name_values :: 1 | 2 | 3 148 | @type unquote(Macro.var(:"#{@name}_values", __MODULE__)) :: unquote(values_ast) 149 | 150 | # @type name :: :key1 | :key2 | :key3 | 1 | 2 | 3 151 | @type unquote(Macro.var(@name, __MODULE__)) :: 152 | unquote( 153 | Macro.postwalk(keys_ast, fn 154 | ^last_key = x -> {:|, [], [x, values_ast]} 155 | x -> x 156 | end) 157 | ) 158 | end 159 | end 160 | end 161 | 162 | defp def_fast_arity_1() do 163 | quote unquote: false, location: :keep do 164 | defmacro unquote(@name)(value) do 165 | case Macro.expand(value, __CALLER__) do 166 | ## Introspecton 167 | # def name(:__keys__), do: @keys 168 | :__keys__ -> unquote(@keys) 169 | # def name(:__values__), do: @values 170 | :__values__ -> unquote(@values) 171 | # def name(:__enumerators__), do: @fields 172 | :__enumerators__ -> unquote(@fields) 173 | # 174 | ## Fast/Compile time Access 175 | # def name(key), do: value 176 | x when x in unquote(@keys) -> Keyword.fetch!(unquote(@fields), x) 177 | # def name(value), do: key 178 | x when x in unquote(@values) -> Map.fetch!(unquote(@fields_rev), x) 179 | # 180 | ## Callback to slow access 181 | x -> slow_arity_1(x, @fields, @fields_rev, @enum_name) 182 | end 183 | end 184 | end 185 | end 186 | 187 | defp def_fast_arity_2() do 188 | quote unquote: false, location: :keep do 189 | defmacro unquote(@name)(value, type) do 190 | expanded_val = Macro.expand(value, __CALLER__) 191 | expanded_type = Macro.expand(type, __CALLER__) 192 | 193 | case {expanded_val, expanded_type} do 194 | ## Fast/Compile time Access 195 | # def name(key, :key), do: key 196 | {x, :key} when x in unquote(@keys) -> x 197 | # def name(value, :value), do: value 198 | {x, :value} when x in unquote(@values) -> x 199 | # def name(value, :key), do: key 200 | {x, :key} when x in unquote(@values) -> Map.fetch!(unquote(@fields_rev), x) 201 | # def name(key, :value), do: value 202 | {x, :value} when x in unquote(@keys) -> Keyword.fetch!(unquote(@fields), x) 203 | # def name(key, :tuple), do: {key, value} 204 | {x, :tuple} when x in unquote(@keys) -> {x, Keyword.fetch!(unquote(@fields), x)} 205 | # def name(value, :tuple), do: {key, value} 206 | {x, :tuple} when x in unquote(@values) -> {Map.fetch!(unquote(@fields_rev), x), x} 207 | # 208 | ## Callback to slow access 209 | x -> slow_arity_2(x, @fields, @fields_rev, @enum_name) 210 | end 211 | end 212 | end 213 | end 214 | 215 | defp def_slow_arity_1() do 216 | quote unquote: false, location: :keep, generated: true do 217 | defp slow_arity_1(expanded_val, fields, fields_rev, enum_name) do 218 | keys = Keyword.keys(fields) 219 | values = Keyword.values(fields) 220 | 221 | quote do 222 | value_error = """ 223 | invalid value #{inspect(unquote(expanded_val))} for Enum #{unquote(enum_name)}/1. \ 224 | Expected one of #{inspect(List.flatten([unquote(keys) | unquote(values)]))} 225 | """ 226 | 227 | case unquote(expanded_val) do 228 | ## Introspecton (cf. Fast Access for more details) 229 | :__keys__ -> unquote(keys) 230 | :__values__ -> unquote(values) 231 | :__enumerators__ -> unquote(fields) 232 | ## Slow/Runtime Access (cf. Fast Access for more details) 233 | x when x in unquote(keys) -> Keyword.fetch!(unquote(fields), x) 234 | x when x in unquote(values) -> Map.fetch!(unquote(fields_rev), x) 235 | ## Error handling 236 | x -> raise ArgumentError, value_error 237 | end 238 | end 239 | end 240 | end 241 | end 242 | 243 | defp def_slow_arity_2() do 244 | quote unquote: false, location: :keep, generated: true do 245 | defp slow_arity_2({value, type} = expanded_tuple, fields, fields_rev, enum_name) do 246 | keys = Keyword.keys(fields) 247 | values = Keyword.values(fields) 248 | 249 | quote do 250 | value_error = """ 251 | invalid value #{inspect(unquote(value))} for Enum #{unquote(enum_name)}/2. \ 252 | Expected one of #{inspect(List.flatten([unquote(keys) | unquote(values)]))} 253 | """ 254 | 255 | type_error = """ 256 | invalid type #{inspect(unquote(type))}. Expected one of \ 257 | #{inspect(unquote(@types))} 258 | """ 259 | 260 | case unquote(expanded_tuple) do 261 | ## Slow/Runtime Access (cf. Fast Access for more details) 262 | {x, :key} when x in unquote(keys) -> x 263 | {x, :value} when x in unquote(values) -> x 264 | {x, :key} when x in unquote(values) -> Map.fetch!(unquote(fields_rev), x) 265 | {x, :value} when x in unquote(keys) -> Keyword.fetch!(unquote(fields), x) 266 | {x, :tuple} when x in unquote(keys) -> {x, Keyword.fetch!(unquote(fields), x)} 267 | {x, :tuple} when x in unquote(values) -> {Map.fetch!(unquote(fields_rev), x), x} 268 | ## Error handling 269 | {_, t} when t not in unquote(@types) -> raise ArgumentError, type_error 270 | {x, _} -> raise ArgumentError, value_error 271 | end 272 | end 273 | end 274 | end 275 | end 276 | 277 | ## Internal functions 278 | 279 | defguardp is_kv(kv) when is_tuple(kv) and tuple_size(kv) == 2 and is_atom(elem(kv, 0)) 280 | defguardp is_integer_kv(kv) when is_kv(kv) and is_integer(elem(kv, 1)) 281 | defguardp is_string_kv(kv) when is_kv(kv) and is_binary(elem(kv, 1)) 282 | 283 | defp kv_to_fields(kv, enum_name, caller) do 284 | case kv do 285 | [k | _] when is_atom(k) -> 286 | int_kv_to_fields(kv, enum_name, caller) 287 | 288 | [ikv | _] when is_integer_kv(ikv) -> 289 | int_kv_to_fields(kv, enum_name, caller) 290 | 291 | [skv | _] when is_string_kv(skv) -> 292 | str_kv_to_fields(kv, enum_name, caller) 293 | 294 | [] -> 295 | raise CompileError, 296 | file: caller.file, 297 | line: caller.line, 298 | description: "enum #{enum_name} cannot be empty" 299 | 300 | x -> 301 | raise CompileError, 302 | file: caller.file, 303 | line: caller.line, 304 | description: "invalid fields for enum #{enum_name}. Got #{inspect(x)}" 305 | end 306 | end 307 | 308 | defp int_kv_to_fields(kv, enum_name, caller) do 309 | kv 310 | |> Enum.reduce({[], 0}, fn 311 | key, {result, counter} when is_atom(key) -> 312 | {[{key, counter} | result], counter + 1} 313 | 314 | {key, counter} = kv, {result, _} when is_integer_kv(kv) -> 315 | {[{key, counter} | result], counter + 1} 316 | 317 | value, _ -> 318 | raise CompileError, 319 | file: caller.file, 320 | line: caller.line, 321 | description: "invalid fields #{inspect(value)} for integer-based enum #{enum_name}" 322 | end) 323 | |> Kernel.elem(0) 324 | |> Enum.reverse() 325 | end 326 | 327 | defp str_kv_to_fields(kv, enum_name, caller) do 328 | kv 329 | |> Enum.reduce([], fn 330 | kv, result when is_string_kv(kv) -> 331 | [kv | result] 332 | 333 | value, _ -> 334 | raise CompileError, 335 | file: caller.file, 336 | line: caller.line, 337 | description: "invalid fields #{inspect(value)} for string-based enum #{enum_name}" 338 | end) 339 | |> Enum.reverse() 340 | end 341 | 342 | defp raise_if_duplicate!(type, list, enum_name, caller) do 343 | dups = list -- Enum.uniq(list) 344 | 345 | if length(dups) > 0 do 346 | raise CompileError, 347 | file: caller.file, 348 | line: caller.line, 349 | description: "duplicate #{type} #{inspect(Enum.at(dups, 0))} found in enum #{enum_name}" 350 | end 351 | end 352 | end 353 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SimpleEnum.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.1.0" 5 | @source_url "https://github.com/ImNotAVirus/simple_enum" 6 | 7 | def project do 8 | [ 9 | app: :simple_enum, 10 | version: @version, 11 | elixir: "~> 1.10", 12 | start_permanent: Mix.env() == :prod, 13 | consolidate_protocols: Mix.env() != :test, 14 | deps: deps(), 15 | package: package(), 16 | description: description(), 17 | docs: docs(), 18 | test_coverage: [tool: ExCoveralls], 19 | preferred_cli_env: [ 20 | docs: :docs, 21 | coveralls: :test, 22 | "coveralls.detail": :test, 23 | "coveralls.post": :test, 24 | "coveralls.html": :test 25 | ] 26 | ] 27 | end 28 | 29 | # Run "mix help compile.app" to learn about applications. 30 | def application do 31 | [] 32 | end 33 | 34 | # Run "mix help deps" to learn about dependencies. 35 | defp deps do 36 | [ 37 | {:ex_doc, "~> 0.26", only: [:dev, :docs], runtime: false}, 38 | {:excoveralls, "~> 0.14", only: :test} 39 | ] 40 | end 41 | 42 | defp description do 43 | """ 44 | A simple library that implements Enumerations in Elixir 45 | """ 46 | end 47 | 48 | defp package do 49 | [ 50 | maintainers: ["DarkyZ aka NotAVirus"], 51 | licenses: ["MIT"], 52 | links: %{"GitHub" => @source_url}, 53 | files: ~w(lib CHANGELOG.md LICENSE.md mix.exs README.md .formatter.exs) 54 | ] 55 | end 56 | 57 | defp docs() do 58 | [ 59 | main: "overview", 60 | source_ref: "v#{@version}", 61 | canonical: "http://hexdocs.pm/simple_enum", 62 | source_url: @source_url, 63 | extras: extras(), 64 | groups_for_extras: groups_for_extras() 65 | ] 66 | end 67 | 68 | defp extras do 69 | [ 70 | "guides/integer_based_enum.md", 71 | "guides/string_based_enum.md", 72 | "guides/enum_types.md", 73 | "guides/introspection.md", 74 | "guides/fast_vs_slow_access.md", 75 | "CHANGELOG.md", 76 | "LICENSE.md", 77 | "README.md": [filename: "overview", title: "Overview"] 78 | ] 79 | end 80 | 81 | defp groups_for_extras do 82 | [ 83 | Guides: ~r/guides\/[^\/]+\.md/, 84 | Others: ~r/(CHANGELOG|LICENSE)\.md/ 85 | ] 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, 4 | "ex_doc": {:hex, :ex_doc, "0.26.0", "1922164bac0b18b02f84d6f69cab1b93bc3e870e2ad18d5dacb50a9e06b542a3", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2775d66e494a9a48355db7867478ffd997864c61c65a47d31c4949459281c78d"}, 5 | "excoveralls": {:hex, :excoveralls, "0.14.4", "295498f1ae47bdc6dce59af9a585c381e1aefc63298d48172efaaa90c3d251db", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e3ab02f2df4c1c7a519728a6f0a747e71d7d6e846020aae338173619217931c1"}, 6 | "hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"}, 7 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 8 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 9 | "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 12 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 13 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, 15 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 16 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 17 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 18 | } 19 | -------------------------------------------------------------------------------- /test/fixtures.exs: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Enums do 2 | import SimpleEnum, only: [defenum: 2] 3 | 4 | defenum :color, ~w(blue green red)a 5 | defenum :state, [{:active, 1}, :inactive, {:unknown, -1}, :default] 6 | defenum :day, monday: "MON", tuesday: "TUE", wednesday: "WED" 7 | end 8 | -------------------------------------------------------------------------------- /test/simple_enum_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("fixtures.exs", __DIR__) 2 | 3 | defmodule SimpleEnumTest do 4 | use ExUnit.Case, async: true 5 | 6 | import ExUnit.CaptureIO 7 | 8 | require SimpleEnum 9 | # doctest SimpleEnum 10 | 11 | require MyApp.Enums 12 | 13 | describe "defenum/2" do 14 | test "define @type enum" do 15 | code = """ 16 | defmodule ExtractGlobalTypeEnum do 17 | import SimpleEnum, only: [defenum: 2] 18 | 19 | # Little trick to get module bytecode 20 | # I don't know if there is a better way to do that 21 | # Maybe use Compiler Tracing ? 22 | @after_compile __MODULE__ 23 | 24 | defenum :color, ~w(blue green red)a 25 | defenum :day, monday: "MON", tuesday: "TUE", wednesday: "WED" 26 | 27 | def __after_compile__(_env, bytecode) do 28 | {:ok, abstract_code} = typespecs_abstract_code(bytecode) 29 | :io.fwrite('~s~n', [:erl_prettypr.format(:erl_syntax.form_list(abstract_code))]) 30 | end 31 | 32 | # From https://github.com/elixir-lang/elixir/blob/main/lib/elixir/lib/code/typespec.ex#L156 33 | defp typespecs_abstract_code(binary) do 34 | with {:ok, {_, [debug_info: {:debug_info_v1, _backend, data}]}} <- 35 | :beam_lib.chunks(binary, [:debug_info]), 36 | {:elixir_v1, %{}, specs} <- data do 37 | {:ok, specs} 38 | else 39 | _ -> :error 40 | end 41 | end 42 | end 43 | """ 44 | 45 | log = capture_io(fn -> Code.compile_string(code) end) 46 | 47 | ## Erlang syntax 48 | assert log =~ "-export_type([day/0])." 49 | 50 | assert log =~ """ 51 | -type day() :: monday | 52 | tuesday | 53 | wednesday | 54 | 'Elixir.String':t(). 55 | """ 56 | 57 | assert log =~ "-export_type([color/0])." 58 | assert log =~ "-type color() :: blue | green | red | 0 | 1 | 2." 59 | end 60 | 61 | test "define @type enum_keys" do 62 | code = """ 63 | defmodule ExtractKeysTypeEnum do 64 | import SimpleEnum, only: [defenum: 2] 65 | 66 | # Little trick to get module bytecode 67 | # I don't know if there is a better way to do that 68 | # Maybe use Compiler Tracing ? 69 | @after_compile __MODULE__ 70 | 71 | defenum :color, ~w(blue green red)a 72 | defenum :day, monday: "MON", tuesday: "TUE", wednesday: "WED" 73 | 74 | def __after_compile__(_env, bytecode) do 75 | {:ok, abstract_code} = typespecs_abstract_code(bytecode) 76 | :io.fwrite('~s~n', [:erl_prettypr.format(:erl_syntax.form_list(abstract_code))]) 77 | end 78 | 79 | # From https://github.com/elixir-lang/elixir/blob/main/lib/elixir/lib/code/typespec.ex#L156 80 | defp typespecs_abstract_code(binary) do 81 | with {:ok, {_, [debug_info: {:debug_info_v1, _backend, data}]}} <- 82 | :beam_lib.chunks(binary, [:debug_info]), 83 | {:elixir_v1, %{}, specs} <- data do 84 | {:ok, specs} 85 | else 86 | _ -> :error 87 | end 88 | end 89 | end 90 | """ 91 | 92 | log = capture_io(fn -> Code.compile_string(code) end) 93 | 94 | ## Erlang syntax 95 | assert log =~ "-export_type([day_keys/0])." 96 | assert log =~ "-type day_keys() :: monday | tuesday | wednesday." 97 | 98 | assert log =~ "-export_type([color_keys/0])." 99 | assert log =~ "-type color_keys() :: blue | green | red." 100 | end 101 | 102 | test "define @type enum_values" do 103 | code = """ 104 | defmodule ExtractValuesTypeEnum do 105 | import SimpleEnum, only: [defenum: 2] 106 | 107 | # Little trick to get module bytecode 108 | # I don't know if there is a better way to do that 109 | # Maybe use Compiler Tracing ? 110 | @after_compile __MODULE__ 111 | 112 | defenum :color, ~w(blue green red)a 113 | defenum :day, monday: "MON", tuesday: "TUE", wednesday: "WED" 114 | 115 | def __after_compile__(_env, bytecode) do 116 | {:ok, abstract_code} = typespecs_abstract_code(bytecode) 117 | :io.fwrite('~s~n', [:erl_prettypr.format(:erl_syntax.form_list(abstract_code))]) 118 | end 119 | 120 | # From https://github.com/elixir-lang/elixir/blob/main/lib/elixir/lib/code/typespec.ex#L156 121 | defp typespecs_abstract_code(binary) do 122 | with {:ok, {_, [debug_info: {:debug_info_v1, _backend, data}]}} <- 123 | :beam_lib.chunks(binary, [:debug_info]), 124 | {:elixir_v1, %{}, specs} <- data do 125 | {:ok, specs} 126 | else 127 | _ -> :error 128 | end 129 | end 130 | end 131 | """ 132 | 133 | log = capture_io(fn -> Code.compile_string(code) end) 134 | 135 | ## Erlang syntax 136 | assert log =~ "-export_type([day_values/0])." 137 | assert log =~ "-type day_values() :: 'Elixir.String':t()." 138 | 139 | assert log =~ "-export_type([color_values/0])." 140 | assert log =~ "-type color_values() :: 0 | 1 | 2." 141 | end 142 | end 143 | 144 | describe "defenum/2 do not compile when" do 145 | test "key/value pair is empty" do 146 | code = """ 147 | defmodule EmptyEnum do 148 | import SimpleEnum, only: [defenum: 2] 149 | 150 | defenum :test, [] 151 | end 152 | """ 153 | 154 | assert_raise CompileError, 155 | "nofile:4: enum EmptyEnum.test cannot be empty", 156 | fn -> 157 | Code.compile_string(code) 158 | end 159 | end 160 | 161 | test "invalid field is found" do 162 | code = """ 163 | defmodule InvalidEnum do 164 | import SimpleEnum, only: [defenum: 2] 165 | 166 | defenum :test, [invalid: :field] 167 | end 168 | """ 169 | 170 | code2 = """ 171 | defmodule InvalidEnum do 172 | import SimpleEnum, only: [defenum: 2] 173 | 174 | defenum :test, 123 175 | end 176 | """ 177 | 178 | assert_raise CompileError, 179 | "nofile:4: invalid fields for enum InvalidEnum.test. Got [invalid: :field]", 180 | fn -> 181 | Code.compile_string(code) 182 | end 183 | 184 | assert_raise CompileError, 185 | "nofile:4: invalid fields for enum InvalidEnum.test. Got 123", 186 | fn -> 187 | Code.compile_string(code2) 188 | end 189 | end 190 | 191 | test "invalid field is found (Integer-based enum)" do 192 | code = """ 193 | defmodule Enums do 194 | import SimpleEnum, only: [defenum: 2] 195 | 196 | defenum :test, [default: 0, invalid: :field] 197 | end 198 | """ 199 | 200 | code2 = """ 201 | defmodule Enums do 202 | import SimpleEnum, only: [defenum: 2] 203 | 204 | defenum :test, [:default, invalid: :field] 205 | end 206 | """ 207 | 208 | assert_raise CompileError, 209 | "nofile:4: invalid fields {:invalid, :field} for integer-based enum Enums.test", 210 | fn -> 211 | Code.compile_string(code) 212 | end 213 | 214 | assert_raise CompileError, 215 | "nofile:4: invalid fields {:invalid, :field} for integer-based enum Enums.test", 216 | fn -> 217 | Code.compile_string(code2) 218 | end 219 | end 220 | 221 | test "invalid field is found (String-based enum)" do 222 | code = """ 223 | defmodule Enums do 224 | import SimpleEnum, only: [defenum: 2] 225 | 226 | defenum :test, [default: "DEFAULT", invalid: :field] 227 | end 228 | """ 229 | 230 | code2 = """ 231 | defmodule Enums do 232 | import SimpleEnum, only: [defenum: 2] 233 | 234 | defenum :test, [{:default, "DEFAULT"}, :invalid] 235 | end 236 | """ 237 | 238 | assert_raise CompileError, 239 | "nofile:4: invalid fields {:invalid, :field} for string-based enum Enums.test", 240 | fn -> 241 | Code.compile_string(code) 242 | end 243 | 244 | assert_raise CompileError, 245 | "nofile:4: invalid fields :invalid for string-based enum Enums.test", 246 | fn -> 247 | Code.compile_string(code2) 248 | end 249 | end 250 | 251 | test "duplicate key is found" do 252 | code = """ 253 | defmodule DuplicateKeyEnum do 254 | import SimpleEnum, only: [defenum: 2] 255 | 256 | defenum :test, ~w(a b c a)a 257 | end 258 | """ 259 | 260 | assert_raise CompileError, 261 | "nofile:4: duplicate key :a found in enum DuplicateKeyEnum.test", 262 | fn -> 263 | Code.compile_string(code) 264 | end 265 | end 266 | 267 | test "duplicate value is found" do 268 | code = """ 269 | defmodule DuplicateValueEnum do 270 | import SimpleEnum, only: [defenum: 2] 271 | 272 | defenum :test, [{:a, 1}, :b, :c, {:d, 3}] 273 | end 274 | """ 275 | 276 | assert_raise CompileError, 277 | "nofile:4: duplicate value 3 found in enum DuplicateValueEnum.test", 278 | fn -> 279 | Code.compile_string(code) 280 | end 281 | end 282 | end 283 | 284 | describe "metadata helpers" do 285 | test "with compile time access (fast)" do 286 | assert MyApp.Enums.color(:__keys__) == [:blue, :green, :red] 287 | assert MyApp.Enums.color(:__values__) == [0, 1, 2] 288 | assert MyApp.Enums.color(:__enumerators__) == [blue: 0, green: 1, red: 2] 289 | end 290 | 291 | test "with runtime access (slow)" do 292 | type1 = :__keys__ 293 | assert MyApp.Enums.color(type1) == [:blue, :green, :red] 294 | type2 = :__values__ 295 | assert MyApp.Enums.color(type2) == [0, 1, 2] 296 | type3 = :__enumerators__ 297 | assert MyApp.Enums.color(type3) == [blue: 0, green: 1, red: 2] 298 | end 299 | 300 | test "for default integer values" do 301 | assert MyApp.Enums.state(:__keys__) == [:active, :inactive, :unknown, :default] 302 | assert MyApp.Enums.state(:__values__) == [1, 2, -1, 0] 303 | 304 | assert MyApp.Enums.state(:__enumerators__) == [ 305 | active: 1, 306 | inactive: 2, 307 | unknown: -1, 308 | default: 0 309 | ] 310 | end 311 | 312 | test "for default string values" do 313 | assert MyApp.Enums.day(:__keys__) == [:monday, :tuesday, :wednesday] 314 | assert MyApp.Enums.day(:__values__) == ["MON", "TUE", "WED"] 315 | 316 | assert MyApp.Enums.day(:__enumerators__) == [ 317 | monday: "MON", 318 | tuesday: "TUE", 319 | wednesday: "WED" 320 | ] 321 | end 322 | 323 | test "can be used in guards" do 324 | got = 325 | case 2 do 326 | x when x in MyApp.Enums.color(:__values__) -> :ok 327 | end 328 | 329 | assert got == :ok 330 | end 331 | end 332 | 333 | describe "enum/1" do 334 | test "with compile time access (fast)" do 335 | assert MyApp.Enums.color(:blue) == 0 336 | assert MyApp.Enums.color(:green) == 1 337 | assert MyApp.Enums.color(:red) == 2 338 | 339 | assert MyApp.Enums.color(0) == :blue 340 | assert MyApp.Enums.color(1) == :green 341 | assert MyApp.Enums.color(2) == :red 342 | end 343 | 344 | test "with runtime access (slow)" do 345 | key1 = :blue 346 | assert MyApp.Enums.color(key1) == 0 347 | key2 = :green 348 | assert MyApp.Enums.color(key2) == 1 349 | key3 = :red 350 | assert MyApp.Enums.color(key3) == 2 351 | 352 | value1 = 0 353 | assert MyApp.Enums.color(value1) == :blue 354 | value2 = 1 355 | assert MyApp.Enums.color(value2) == :green 356 | value3 = 2 357 | assert MyApp.Enums.color(value3) == :red 358 | end 359 | 360 | test "can be used in guards" do 361 | got = 362 | case 2 do 363 | x when x == MyApp.Enums.color(:red) -> :ok 364 | end 365 | 366 | assert got == :ok 367 | end 368 | 369 | @color_key :red 370 | test "can be used with module attributes" do 371 | got = 372 | case 2 do 373 | x when x == MyApp.Enums.color(@color_key) -> :ok 374 | end 375 | 376 | assert got == :ok 377 | end 378 | 379 | test "raises if invalid value" do 380 | assert_raise ArgumentError, ~r"^invalid value :invalid for Enum MyApp.Enums.color/1", fn -> 381 | MyApp.Enums.color(:invalid) 382 | end 383 | end 384 | end 385 | 386 | describe "enum/2" do 387 | test "with compile time access (fast)" do 388 | assert MyApp.Enums.color(:blue, :key) == :blue 389 | assert MyApp.Enums.color(:blue, :value) == 0 390 | assert MyApp.Enums.color(:blue, :tuple) == {:blue, 0} 391 | 392 | assert MyApp.Enums.color(0, :key) == :blue 393 | assert MyApp.Enums.color(0, :value) == 0 394 | assert MyApp.Enums.color(0, :tuple) == {:blue, 0} 395 | 396 | assert MyApp.Enums.color(:green, :key) == :green 397 | assert MyApp.Enums.color(:green, :value) == 1 398 | assert MyApp.Enums.color(:green, :tuple) == {:green, 1} 399 | 400 | assert MyApp.Enums.color(1, :key) == :green 401 | assert MyApp.Enums.color(1, :value) == 1 402 | assert MyApp.Enums.color(1, :tuple) == {:green, 1} 403 | 404 | assert MyApp.Enums.color(:red, :key) == :red 405 | assert MyApp.Enums.color(:red, :value) == 2 406 | assert MyApp.Enums.color(:red, :tuple) == {:red, 2} 407 | 408 | assert MyApp.Enums.color(2, :key) == :red 409 | assert MyApp.Enums.color(2, :value) == 2 410 | assert MyApp.Enums.color(2, :tuple) == {:red, 2} 411 | end 412 | 413 | test "with runtime access (slow)" do 414 | key = :blue 415 | assert MyApp.Enums.color(key, :key) == :blue 416 | assert MyApp.Enums.color(key, :value) == 0 417 | assert MyApp.Enums.color(key, :tuple) == {:blue, 0} 418 | 419 | value = 0 420 | assert MyApp.Enums.color(value, :key) == :blue 421 | assert MyApp.Enums.color(value, :value) == 0 422 | assert MyApp.Enums.color(value, :tuple) == {:blue, 0} 423 | end 424 | 425 | test "can be used in guards" do 426 | got = 427 | case {:red, 2} do 428 | x when x == MyApp.Enums.color(:red, :tuple) -> :ok 429 | end 430 | 431 | assert got == :ok 432 | end 433 | 434 | @color_key :red 435 | @enum_type :tuple 436 | test "can be used with module attributes" do 437 | got = 438 | case {:red, 2} do 439 | x when x == MyApp.Enums.color(@color_key, @enum_type) -> :ok 440 | end 441 | 442 | assert got == :ok 443 | end 444 | 445 | test "raises if invalid value" do 446 | assert_raise ArgumentError, ~r"^invalid value :invalid for Enum MyApp.Enums.color/2", fn -> 447 | MyApp.Enums.color(:invalid, :key) 448 | end 449 | 450 | assert_raise ArgumentError, ~r"^invalid type :keyys. Expected one of", fn -> 451 | MyApp.Enums.color(:blue, :keyys) 452 | end 453 | end 454 | end 455 | end 456 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------