├── .check.exs ├── .credo.exs ├── .dialyzer_ignore ├── .editorconfig ├── .envrc ├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitsetup ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib ├── typed_struct.ex └── typed_struct │ └── plugin.ex ├── mix.exs ├── mix.lock ├── shell.nix └── test ├── test_helper.exs ├── typed_struct ├── plugin_test.exs └── plugin_type_test.exs └── typed_struct_test.exs /.check.exs: -------------------------------------------------------------------------------- 1 | # Check if the version used by the runner is currently the last supported 2 | # version, that is: the version used in the Nix shell on development machines. 3 | last_supported_version? = 4 | System.version() 5 | |> Version.parse!() 6 | |> Version.match?("~> 1.13.0") 7 | 8 | [ 9 | skipped: false, 10 | tools: [ 11 | {:compiler, "mix compile --force --verbose --warnings-as-errors"}, 12 | {:ex_unit, "mix test --trace"}, 13 | 14 | # Run the formatter and ex_doc only for the last supported version. This 15 | # avoids errors in CI when the formatting changes or ex_doc is not 16 | # compatible with an old Elixir version. 17 | {:formatter, last_supported_version?}, 18 | {:ex_doc, last_supported_version?}, 19 | 20 | # Check for unused dependencies in the mix.lock. 21 | {:unused_deps, "mix deps.unlock --check-unused", 22 | enabled: last_supported_version?} 23 | ] 24 | ] 25 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: true, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, 86 | [ 87 | priority: :low, 88 | if_nested_deeper_than: 2, 89 | if_called_more_often_than: 0 90 | ]}, 91 | # You can also customize the exit_status of each check. 92 | # If you don't want TODO comments to cause `mix credo` to fail, just 93 | # set this value to 0 (zero). 94 | # 95 | {Credo.Check.Design.TagTODO, [exit_status: 0]}, 96 | {Credo.Check.Design.TagFIXME, []}, 97 | 98 | # 99 | ## Readability Checks 100 | # 101 | {Credo.Check.Readability.AliasOrder, []}, 102 | {Credo.Check.Readability.FunctionNames, []}, 103 | {Credo.Check.Readability.LargeNumbers, []}, 104 | {Credo.Check.Readability.MaxLineLength, 105 | [priority: :low, max_length: 80]}, 106 | {Credo.Check.Readability.ModuleAttributeNames, []}, 107 | {Credo.Check.Readability.ModuleDoc, []}, 108 | {Credo.Check.Readability.ModuleNames, []}, 109 | {Credo.Check.Readability.ParenthesesInCondition, []}, 110 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 111 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 112 | {Credo.Check.Readability.PredicateFunctionNames, []}, 113 | {Credo.Check.Readability.PreferImplicitTry, []}, 114 | {Credo.Check.Readability.RedundantBlankLines, []}, 115 | {Credo.Check.Readability.Semicolons, []}, 116 | {Credo.Check.Readability.SpaceAfterCommas, []}, 117 | {Credo.Check.Readability.StringSigils, []}, 118 | {Credo.Check.Readability.TrailingBlankLine, []}, 119 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 120 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 121 | {Credo.Check.Readability.VariableNames, []}, 122 | {Credo.Check.Readability.WithSingleClause, []}, 123 | 124 | # 125 | ## Refactoring Opportunities 126 | # 127 | {Credo.Check.Refactor.Apply, []}, 128 | {Credo.Check.Refactor.CondStatements, []}, 129 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 130 | {Credo.Check.Refactor.FunctionArity, []}, 131 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 132 | {Credo.Check.Refactor.MatchInCondition, []}, 133 | {Credo.Check.Refactor.MapJoin, []}, 134 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 135 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 136 | {Credo.Check.Refactor.Nesting, []}, 137 | {Credo.Check.Refactor.UnlessWithElse, []}, 138 | {Credo.Check.Refactor.WithClauses, []}, 139 | {Credo.Check.Refactor.FilterFilter, []}, 140 | {Credo.Check.Refactor.RejectReject, []}, 141 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 142 | 143 | # 144 | ## Warnings 145 | # 146 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 147 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 148 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 149 | {Credo.Check.Warning.IExPry, []}, 150 | {Credo.Check.Warning.IoInspect, []}, 151 | {Credo.Check.Warning.OperationOnSameValues, []}, 152 | {Credo.Check.Warning.OperationWithConstantResult, []}, 153 | {Credo.Check.Warning.RaiseInsideRescue, []}, 154 | {Credo.Check.Warning.SpecWithStruct, []}, 155 | {Credo.Check.Warning.WrongTestFileExtension, []}, 156 | {Credo.Check.Warning.UnusedEnumOperation, []}, 157 | {Credo.Check.Warning.UnusedFileOperation, []}, 158 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 159 | {Credo.Check.Warning.UnusedListOperation, []}, 160 | {Credo.Check.Warning.UnusedPathOperation, []}, 161 | {Credo.Check.Warning.UnusedRegexOperation, []}, 162 | {Credo.Check.Warning.UnusedStringOperation, []}, 163 | {Credo.Check.Warning.UnusedTupleOperation, []}, 164 | {Credo.Check.Warning.UnsafeExec, []} 165 | ], 166 | disabled: [ 167 | # 168 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 169 | 170 | # 171 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 172 | # and be sure to use `mix credo --strict` to see low priority checks) 173 | # 174 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 175 | {Credo.Check.Consistency.UnusedVariableNames, []}, 176 | {Credo.Check.Design.DuplicatedCode, []}, 177 | {Credo.Check.Design.SkipTestWithoutComment, []}, 178 | {Credo.Check.Readability.AliasAs, []}, 179 | {Credo.Check.Readability.BlockPipe, []}, 180 | {Credo.Check.Readability.ImplTrue, []}, 181 | {Credo.Check.Readability.MultiAlias, []}, 182 | {Credo.Check.Readability.SeparateAliasRequire, []}, 183 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 184 | {Credo.Check.Readability.SinglePipe, []}, 185 | {Credo.Check.Readability.Specs, []}, 186 | {Credo.Check.Readability.StrictModuleLayout, []}, 187 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 188 | {Credo.Check.Refactor.ABCSize, []}, 189 | {Credo.Check.Refactor.AppendSingleItem, []}, 190 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 191 | {Credo.Check.Refactor.FilterReject, []}, 192 | {Credo.Check.Refactor.IoPuts, []}, 193 | {Credo.Check.Refactor.MapMap, []}, 194 | {Credo.Check.Refactor.ModuleDependencies, []}, 195 | {Credo.Check.Refactor.NegatedIsNil, []}, 196 | {Credo.Check.Refactor.PipeChainStart, []}, 197 | {Credo.Check.Refactor.RejectFilter, []}, 198 | {Credo.Check.Refactor.VariableRebinding, []}, 199 | {Credo.Check.Warning.LazyLogging, []}, 200 | {Credo.Check.Warning.LeakyEnvironment, []}, 201 | {Credo.Check.Warning.MapGetUnsafePass, []}, 202 | {Credo.Check.Warning.MixEnv, []}, 203 | {Credo.Check.Warning.UnsafeToAtom, []} 204 | 205 | # {Credo.Check.Refactor.MapInto, []}, 206 | 207 | # 208 | # Custom checks can be created using `mix credo.gen.check`. 209 | # 210 | ] 211 | } 212 | } 213 | ] 214 | } 215 | -------------------------------------------------------------------------------- /.dialyzer_ignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ejpcmac/typed_struct/7c26d1654097476ebae9944ba6675a0f3fd21e9d/.dialyzer_ignore -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.{md,sh}] 15 | indent_size = 4 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use nix 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [field: 2, field: 3, plugin: 1, plugin: 2] 2 | 3 | [ 4 | inputs: [ 5 | "{mix,.iex,.formatter,.credo}.exs", 6 | "{config,lib,test}/**/*.{ex,exs}" 7 | ], 8 | line_length: 80, 9 | locals_without_parens: locals_without_parens, 10 | export: [locals_without_parens: locals_without_parens] 11 | ] 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI checks" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | - release/* 9 | - feature/* 10 | pull_request: 11 | branches: 12 | - develop 13 | 14 | jobs: 15 | check: 16 | name: "CI checks for typed_struct [OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}]" 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - elixir: "1.9" 23 | otp: "22.3" 24 | - elixir: "1.10" 25 | otp: "22.3" 26 | - elixir: "1.10" 27 | otp: "23.3" 28 | - elixir: "1.11" 29 | otp: "23.3" 30 | - elixir: "1.12" 31 | otp: "23.3" 32 | - elixir: "1.12" 33 | otp: "24.0" 34 | - elixir: "1.13" 35 | otp: "23.3" 36 | - elixir: "1.13" 37 | otp: "24.0" 38 | steps: 39 | - name: Checkout the repository 40 | uses: actions/checkout@v2.4.0 41 | - name: Install Elixir/OTP 42 | uses: erlef/setup-beam@v1.10.0 43 | with: 44 | otp-version: ${{matrix.otp}} 45 | elixir-version: ${{matrix.elixir}} 46 | - name: Fetch the dependencies 47 | run: mix deps.get 48 | - name: Check (mix check) 49 | run: mix check 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## 2 | ## Application artifacts 3 | ## 4 | 5 | # direnv cache for Nix shells 6 | /.direnv/ 7 | 8 | # Elixir build directory 9 | /_build/ 10 | 11 | # Elixir dependencies 12 | /deps/ 13 | /.fetch 14 | 15 | # Elixir binary files 16 | *.beam 17 | *.ez 18 | /typed_struct-*.tar 19 | /typed_struct 20 | 21 | # Test coverage and documentation 22 | /cover/ 23 | /doc/ 24 | 25 | ## 26 | ## Editor artifacts 27 | ## 28 | 29 | /.elixir_ls/ 30 | /.history/ 31 | 32 | ## 33 | ## Crash dumps 34 | ## 35 | 36 | # Erang VM 37 | erl_crash.dump 38 | -------------------------------------------------------------------------------- /.gitsetup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | set -x 5 | 6 | # Setup git-flow 7 | git flow init -d 8 | git config gitflow.prefix.versiontag "v" 9 | git config gitflow.feature.finish.no-ff true 10 | git config gitflow.release.finish.sign true 11 | git config gitflow.hotfix.finish.sign true 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic 7 | Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [0.3.0] - 2022-02-15 10 | 11 | ### Added 12 | 13 | * [Plugin] Add the `field/4` callback with an additional `env` argument. This is 14 | the same as `field/3` but gives access to the environment of the field 15 | definition. 16 | 17 | ### Deprecated 18 | 19 | * [Plugin] Deprecate the `field/3` callback in favour of `field/4`. You should 20 | migrate to `field/4`, simply by adding `_env` as the last argument in your 21 | implementation. Currently, if a plugin implements `field/3`, `field/4` is 22 | derived from it and a compilation warning is emitted. `field/3` will be 23 | removed in TypedStruct 1.0.0. 24 | 25 | ### Removed 26 | 27 | * Drop support for unsupported Elixir versions in the tests and CI. The 28 | library may still be compatible, but this is not tested. 29 | 30 | ### Fixed 31 | 32 | * Fix the lexical scope of the `typestruct` block, so it covers it completely. 33 | Previously, anything defined inside the `typedstruct` block, such as 34 | aliases, would not be available for the field definitions. See #22 and #21 35 | for details. 36 | * Fix a typo in the documentation. 37 | 38 | ## [0.2.1] - 2020-07-19 39 | 40 | ### Added 41 | 42 | * Add the `module: ModuleName` top-level option to create the typed struct in a 43 | submodule. 44 | 45 | ### Changed 46 | 47 | * Update the `@typedoc` example in the documentation to put it inside the 48 | `typedstruct` block and not above. While putting it above works in the 49 | general case, it is mandatory to put it inside the block when defining a 50 | submodule. 51 | 52 | ## [0.2.0] - 2020-05-31 53 | 54 | ### Added 55 | 56 | * Add a plugin API. 57 | 58 | ### Removed 59 | 60 | * Remove reflection support through the `__keys__/0`, `__defaults__/0` and 61 | `__types__/0` function which where defined by TypedStruct in the user 62 | modules. If you rely on these functions, please use the 63 | [TypedStructLegacyReflection](https://github.com/ejpcmac/typed_struct_legacy_reflection) 64 | plugin to enable them again, and consider creating a plugin for your needs. 65 | 66 | ### Fixed 67 | 68 | * Do not enforce fields with a default value set to nil (fixes #14). 69 | * Prefix all internal module attributes and clean them after use (fixes #15). 70 | * Create a scope in the `typedstruct` block to avoid import leaks. 71 | 72 | ## [0.1.4] - 2018-11-13 73 | 74 | ### Added 75 | 76 | * Add the ability to generate an opaque type (#10). 77 | 78 | ## [0.1.3] - 2018-09-06 79 | 80 | ### Fixed 81 | 82 | * Fix a bug where boolean fields with `default: false` where still enforced when 83 | setting `enforce: true` at top-level. 84 | 85 | ## [0.1.2] - 2018-09-06 86 | 87 | ### Added 88 | 89 | * Add the ability to enforce keys by default (#6). 90 | 91 | ### Fixed 92 | 93 | * Clarify the documentation about `runtime: false`. 94 | 95 | ## [0.1.1] - 2018-06-20 96 | 97 | ### Fixed 98 | 99 | * Do not make the type nullable when there is a default value. 100 | 101 | ## [0.1.0] - 2018-06-19 102 | 103 | ### Added 104 | 105 | * Struct definition 106 | * Type definition 107 | * Default values 108 | * Enforced keys 109 | 110 | [0.3.0]: https://github.com/ejpcmac/typed_struct/compare/v0.2.1...v0.3.0 111 | [0.2.1]: https://github.com/ejpcmac/typed_struct/compare/v0.2.0...v0.2.1 112 | [0.2.0]: https://github.com/ejpcmac/typed_struct/compare/v0.1.4...v0.2.0 113 | [0.1.4]: https://github.com/ejpcmac/typed_struct/compare/v0.1.3...v0.1.4 114 | [0.1.3]: https://github.com/ejpcmac/typed_struct/compare/v0.1.2...v0.1.3 115 | [0.1.2]: https://github.com/ejpcmac/typed_struct/compare/v0.1.1...v0.1.2 116 | [0.1.1]: https://github.com/ejpcmac/typed_struct/compare/v0.1.0...v0.1.1 117 | [0.1.0]: https://github.com/ejpcmac/typed_struct/releases/tag/v0.1.0 118 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to TypedStruct 2 | 3 | TypedStruct is written in [Elixir](https://elixir-lang.org). 4 | 5 | For branching management, this project uses 6 | [git-flow](https://github.com/petervanderdoes/gitflow-avh). The `main` branch is 7 | reserved for releases: the development process occurs on `develop` and feature 8 | branches. **Please never commit to `main`.** 9 | 10 | You can easily set up a development environment featuring all the dependencies, 11 | including Elixir and `git-flow`, by using [Nix](https://nixos.org/nix/). This is 12 | detailed below. 13 | 14 | ## Setup 15 | 16 | ### Local repository 17 | 18 | 1. Fork the repository. 19 | 20 | 2. Clone your fork to a local repository: 21 | 22 | $ git clone https://github.com/you/typed_struct.git 23 | $ cd typed_struct 24 | 25 | 3. Add the main repository as a remote: 26 | 27 | $ git remote add upstream https://github.com/ejpcmac/typed_struct.git 28 | 29 | 4. Checkout `develop`: 30 | 31 | $ git checkout develop 32 | 33 | ### Development environment (without Nix) 34 | 35 | Install an Elixir environment, and optionally install `git-flow`. 36 | 37 | ### Development environment (with Nix) 38 | 39 | 1. Install Nix by running the script and following the instructions: 40 | 41 | $ curl https://nixos.org/nix/install | sh 42 | 43 | 2. Optionally install [direnv](https://github.com/direnv/direnv) to 44 | automatically setup the environment when you enter the project directory: 45 | 46 | $ nix-env -i direnv 47 | 48 | In this case, you also need to add to your `~/.rc`: 49 | 50 | ```sh 51 | eval "$(direnv hook )" 52 | ``` 53 | 54 | *Make sure to replace `` by your shell, namely `bash`, `zsh`, …* 55 | 56 | 3. In the project directory, if you **did not** install direnv, start a Nix 57 | shell: 58 | 59 | $ cd typed_struct 60 | $ nix-shell 61 | 62 | If you opted to use direnv, please allow the `.envrc` instead of running a 63 | Nix shell manually: 64 | 65 | $ cd typed_struct 66 | $ direnv allow 67 | 68 | In this case, direnv will automatically update your environment to behave 69 | like a Nix shell whenever you enter the project directory. 70 | 71 | ### Git-flow 72 | 73 | If you want to use `git-flow` and use the standard project configuration, please 74 | run: 75 | 76 | $ ./.gitsetup 77 | 78 | ### Building the project 79 | 80 | 1. Fetch the project dependencies: 81 | 82 | $ cd typed_struct 83 | $ mix deps.get 84 | 85 | 2. Run the static analyzers: 86 | 87 | $ mix check 88 | 89 | All the tests should pass. 90 | 91 | ## Workflow 92 | 93 | To make a change, please use this workflow: 94 | 95 | 1. Checkout `develop` and apply the last upstream changes (use rebase, not 96 | merge!): 97 | 98 | $ git checkout develop 99 | $ git fetch --all --prune 100 | $ git rebase upstream/develop 101 | 102 | 2. For a tiny patch, create a new branch with an explicit name: 103 | 104 | $ git checkout -b 105 | 106 | Alternatively, if you are working on a feature which would need more work, 107 | you can create a feature branch with `git-flow`: 108 | 109 | $ git flow feature start 110 | 111 | *Note: always open an issue and ask before starting a big feature, to avoid 112 | it not beeing merged and your time lost.* 113 | 114 | 3. Work on your feature (don’t forget to write typespecs and tests; you can 115 | check your coverage with `mix coveralls.html` and open 116 | `cover/excoveralls.html`): 117 | 118 | # Some work 119 | $ git commit -am "feat: my first change" 120 | # Some work 121 | $ git commit -am "refactor: my second change" 122 | ... 123 | 124 | 4. When your feature is ready, feel free to use 125 | [interactive rebase](https://help.github.com/articles/about-git-rebase/) so 126 | your history looks clean and is easy to follow. Then, apply the last 127 | upstream changes on `develop` to prepare integration: 128 | 129 | $ git checkout develop 130 | $ git fetch --all --prune 131 | $ git rebase upstream/develop 132 | 133 | 5. If there were commits on `develop` since the beginning of your feature 134 | branch, integrate them by **rebasing** if your branch has few commits, or 135 | merging if you had a long-lived branch: 136 | 137 | $ git checkout 138 | $ git rebase develop 139 | 140 | *Note: the only case you should merge is when you are working on a big 141 | feature. If it is the case, we should have discussed this before as stated 142 | above.* 143 | 144 | 6. Run the tests and static analyzers to ensure there is no regression and all 145 | works as expected: 146 | 147 | $ mix check 148 | 149 | 7. If it’s all good, open a pull request to merge your branch into the `develop` 150 | branch on the main repository. 151 | 152 | ## Coding style 153 | 154 | Please format your code with `mix format` or your editor and follow 155 | [this style guide](https://github.com/christopheradams/elixir_style_guide). 156 | 157 | All contributed code must be documented and functions must have typespecs. In 158 | general, take your inspiration from the existing code. 159 | 160 | Please name your commits using [Conventional 161 | Commits](https://www.conventionalcommits.org/en/v1.0.0/). 162 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright © 2018-2022 Jean-Philippe Cugnet and Contributors 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 | # TypedStruct 2 | 3 | [![Build Status](https://travis-ci.com/ejpcmac/typed_struct.svg?branch=develop)](https://travis-ci.com/ejpcmac/typed_struct) 4 | [![hex.pm version](https://img.shields.io/hexpm/v/typed_struct.svg?style=flat)](https://hex.pm/packages/typed_struct) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg?style=flat)](https://hexdocs.pm/typed_struct/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/typed_struct.svg?style=flat)](https://hex.pm/packages/typed_struct) 7 | [![License](https://img.shields.io/hexpm/l/typed_struct.svg?style=flat)](https://github.com/ejpcmac/typed_struct/blob/master/LICENSE.md) 8 | 9 | 10 | 11 | TypedStruct is a library for defining structs with a type without writing 12 | boilerplate code. 13 | 14 | ## Rationale 15 | 16 | To define a struct in Elixir, you probably want to define three things: 17 | 18 | * the struct itself, with default values, 19 | * the list of enforced keys, 20 | * its associated type. 21 | 22 | It ends up in something like this: 23 | 24 | ```elixir 25 | defmodule Person do 26 | @moduledoc """ 27 | A struct representing a person. 28 | """ 29 | 30 | @enforce_keys [:name] 31 | defstruct name: nil, 32 | age: nil, 33 | happy?: true, 34 | phone: nil 35 | 36 | @typedoc "A person" 37 | @type t() :: %__MODULE__{ 38 | name: String.t(), 39 | age: non_neg_integer() | nil, 40 | happy?: boolean(), 41 | phone: String.t() | nil 42 | } 43 | end 44 | ``` 45 | 46 | In the example above you can notice several points: 47 | 48 | * the keys are present in both the `defstruct` and type definition, 49 | * enforced keys must also be written in `@enforce_keys`, 50 | * if a key has no default value and is not enforced, its type should be 51 | nullable. 52 | 53 | If you want to add a field in the struct, you must therefore: 54 | 55 | * add the key with its default value in the `defstruct` list, 56 | * add the key with its type in the type definition. 57 | 58 | If the field is not optional, you should even add it to `@enforce_keys`. This is 59 | way too much work for lazy people like me, and moreover it can be error-prone. 60 | 61 | It would be way better if we could write something like this: 62 | 63 | ```elixir 64 | defmodule Person do 65 | @moduledoc """ 66 | A struct representing a person. 67 | """ 68 | 69 | use TypedStruct 70 | 71 | typedstruct do 72 | @typedoc "A person" 73 | 74 | field :name, String.t(), enforce: true 75 | field :age, non_neg_integer() 76 | field :happy?, boolean(), default: true 77 | field :phone, String.t() 78 | end 79 | end 80 | ``` 81 | 82 | Thanks to TypedStruct, this is now possible :) 83 | 84 | ## Usage 85 | 86 | ### Setup 87 | 88 | To use TypedStruct in your project, add this to your Mix dependencies: 89 | 90 | ```elixir 91 | {:typed_struct, "~> 0.3.0"} 92 | ``` 93 | 94 | If you do not plan to compile modules using TypedStruct at runtime, you can add 95 | `runtime: false` to the dependency tuple as TypedStruct is only used at build 96 | time. 97 | 98 | If you want to avoid `mix format` putting parentheses on field definitions, 99 | you can add to your `.formatter.exs`: 100 | 101 | ```elixir 102 | [ 103 | ..., 104 | import_deps: [:typed_struct] 105 | ] 106 | ``` 107 | 108 | ### General usage 109 | 110 | To define a typed struct, use 111 | [`TypedStruct`](https://hexdocs.pm/typed_struct/TypedStruct.html), then define 112 | your struct within a `typedstruct` block: 113 | 114 | ```elixir 115 | defmodule MyStruct do 116 | # Use TypedStruct to import the typedstruct macro. 117 | use TypedStruct 118 | 119 | # Define your struct. 120 | typedstruct do 121 | # Define each field with the field macro. 122 | field :a_string, String.t() 123 | 124 | # You can set a default value. 125 | field :string_with_default, String.t(), default: "default" 126 | 127 | # You can enforce a field. 128 | field :enforced_field, integer(), enforce: true 129 | end 130 | end 131 | ``` 132 | 133 | Each field is defined through the 134 | [`field/2`](https://hexdocs.pm/typed_struct/TypedStruct.html#field/2) macro. 135 | 136 | ### Options 137 | 138 | If you want to enforce all the keys by default, you can do: 139 | 140 | ```elixir 141 | defmodule MyStruct do 142 | use TypedStruct 143 | 144 | # Enforce keys by default. 145 | typedstruct enforce: true do 146 | # This key is enforced. 147 | field :enforced_by_default, term() 148 | 149 | # You can override the default behaviour. 150 | field :not_enforced, term(), enforce: false 151 | 152 | # A key with a default value is not enforced. 153 | field :not_enforced_either, integer(), default: 1 154 | end 155 | end 156 | ``` 157 | 158 | You can also generate an opaque type for the struct: 159 | 160 | ```elixir 161 | defmodule MyOpaqueStruct do 162 | use TypedStruct 163 | 164 | # Generate an opaque type for the struct. 165 | typedstruct opaque: true do 166 | field :name, String.t() 167 | end 168 | end 169 | ``` 170 | 171 | If you often define submodules containing only a struct, you can avoid 172 | boilerplate code: 173 | 174 | ```elixir 175 | defmodule MyModule do 176 | use TypedStruct 177 | 178 | # You now have %MyModule.Struct{}. 179 | typedstruct module: Struct do 180 | field :field, term() 181 | end 182 | end 183 | ``` 184 | 185 | ### Documentation 186 | 187 | To add a `@typedoc` to the struct type, just add the attribute in the 188 | `typedstruct` block: 189 | 190 | ```elixir 191 | typedstruct do 192 | @typedoc "A typed struct" 193 | 194 | field :a_string, String.t() 195 | field :an_int, integer() 196 | end 197 | ``` 198 | 199 | You can also document submodules this way: 200 | 201 | ```elixir 202 | typedstruct module: MyStruct do 203 | @moduledoc "A submodule with a typed struct." 204 | @typedoc "A typed struct in a submodule" 205 | 206 | field :a_string, String.t() 207 | field :an_int, integer() 208 | end 209 | ``` 210 | 211 | ## Plugins 212 | 213 | It is possible to extend the scope of TypedStruct by using its plugin interface, 214 | as described in 215 | [`TypedStruct.Plugin`](https://hexdocs.pm/typed_struct/TypedStruct.Plugin.html). 216 | For instance, to automatically generate lenses with the 217 | [Lens](https://github.com/obrok/lens) library, you can use 218 | [`TypedStructLens`](https://github.com/ejpcmac/typed_struct_lens) and do: 219 | 220 | ```elixir 221 | defmodule MyStruct do 222 | use TypedStruct 223 | 224 | typedstruct do 225 | plugin TypedStructLens 226 | 227 | field :a_field, String.t() 228 | field :other_field, atom() 229 | end 230 | 231 | @spec change(t()) :: t() 232 | def change(data) do 233 | # a_field/0 is generated by TypedStructLens. 234 | lens = a_field() 235 | put_in(data, [lens], "Changed") 236 | end 237 | end 238 | ``` 239 | 240 | ### Some available plugins 241 | 242 | * [`typed_struct_lens`](https://github.com/ejpcmac/typed_struct_lens) – 243 | Integration with the [Lens](https://github.com/obrok/lens) library. 244 | * [`typed_struct_legacy_reflection`](https://github.com/ejpcmac/typed_struct_legacy_reflection) 245 | – Re-enables the legacy reflection functions from TypedStruct 0.1.x. 246 | 247 | This list is not meant to be exhaustive, please [search for “typed_struct” on 248 | hex.pm](https://hex.pm/packages?search=typed_struct) for other results. If you 249 | want your plugin to appear here, please open an issue. 250 | 251 | ## What do I get? 252 | 253 | When defining an empty `typedstruct` block: 254 | 255 | ```elixir 256 | defmodule Example do 257 | use TypedStruct 258 | 259 | typedstruct do 260 | end 261 | end 262 | ``` 263 | 264 | you get an empty struct with its module type `t()`: 265 | 266 | ```elixir 267 | defmodule Example do 268 | @enforce_keys [] 269 | defstruct [] 270 | 271 | @type t() :: %__MODULE__{} 272 | end 273 | ``` 274 | 275 | Each `field` call adds information to the struct, `@enforce_keys` and the type 276 | `t()`. 277 | 278 | A field with no options adds the name to the `defstruct` list, with `nil` as 279 | default. The type itself is made nullable: 280 | 281 | ```elixir 282 | defmodule Example do 283 | use TypedStruct 284 | 285 | typedstruct do 286 | field :name, String.t() 287 | end 288 | end 289 | ``` 290 | 291 | becomes: 292 | 293 | ```elixir 294 | defmodule Example do 295 | @enforce_keys [] 296 | defstruct name: nil 297 | 298 | @type t() :: %__MODULE__{ 299 | name: String.t() | nil 300 | } 301 | end 302 | ``` 303 | 304 | The `default` option adds the default value to the `defstruct`: 305 | 306 | ```elixir 307 | field :name, String.t(), default: "John Smith" 308 | 309 | # Becomes 310 | defstruct name: "John Smith" 311 | ``` 312 | 313 | When set to `true`, the `enforce` option enforces the key by adding it to the 314 | `@enforce_keys` attribute. 315 | 316 | ```elixir 317 | field :name, String.t(), enforce: true 318 | 319 | # Becomes 320 | @enforce_keys [:name] 321 | defstruct name: nil 322 | ``` 323 | 324 | In both cases, the type has no reason to be nullable anymore by default. In one 325 | case the field is filled with its default value and not `nil`, and in the other 326 | case it is enforced. Both options would generate the following type: 327 | 328 | ```elixir 329 | @type t() :: %__MODULE__{ 330 | name: String.t() # Not nullable 331 | } 332 | ``` 333 | 334 | Passing `opaque: true` replaces `@type` with `@opaque` in the struct type 335 | specification: 336 | 337 | ```elixir 338 | typedstruct opaque: true do 339 | field :name, String.t() 340 | end 341 | ``` 342 | 343 | generates the following type: 344 | 345 | ```elixir 346 | @opaque t() :: %__MODULE__{ 347 | name: String.t() 348 | } 349 | ``` 350 | 351 | When passing `module: ModuleName`, the whole `typedstruct` block is wrapped in a 352 | module definition. This way, the following definition: 353 | 354 | ```elixir 355 | defmodule MyModule do 356 | use TypedStruct 357 | 358 | typedstruct module: Struct do 359 | field :field, term() 360 | end 361 | end 362 | ``` 363 | 364 | becomes: 365 | 366 | ```elixir 367 | defmodule MyModule do 368 | defmodule Struct do 369 | @enforce_keys [] 370 | defstruct field: nil 371 | 372 | @type t() :: %__MODULE__{ 373 | field: term() | nil 374 | } 375 | end 376 | end 377 | ``` 378 | 379 | 380 | 381 | ## Initial roadmap 382 | 383 | * [x] Struct definition 384 | * [x] Type definition (with nullable types) 385 | * [x] Default values 386 | * [x] Enforced keys (non-nullable types) 387 | * [x] Plugin API 388 | 389 | ## Plugin ideas 390 | 391 | * [ ] Default value type-checking (is it possible?) 392 | * [ ] Guard generation 393 | * [x] Integration with [Lens](https://github.com/obrok/lens) 394 | * [ ] Integration with [Ecto](https://github.com/elixir-ecto/ecto) 395 | 396 | ## Related libraries 397 | 398 | * [Domo](https://github.com/IvanRublev/Domo): a library to validate structs that 399 | define a `t()` type, like the one generated by `TypedStruct`. 400 | * [TypedEctoSchema](https://github.com/bamorim/typed_ecto_schema): a library 401 | that provides a DSL on top of `Ecto.Schema` to achieve the same result as 402 | `TypedStruct`, with `Ecto`. 403 | 404 | ## [Contributing](CONTRIBUTING.md) 405 | 406 | Before contributing to this project, please read the 407 | [CONTRIBUTING.md](CONTRIBUTING.md). 408 | 409 | ## License 410 | 411 | Copyright © 2018-2022 Jean-Philippe Cugnet and Contributors 412 | 413 | This project is licensed under the [MIT license](./LICENSE.md). 414 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # This configuration is loaded before any dependency and is restricted to this 4 | # project. If another project depends on this project, this file won’t be loaded 5 | # nor affect the parent project. For this reason, if you want to provide default 6 | # values for your application for 3rd-party users, it should be done in your 7 | # "mix.exs" file. 8 | 9 | if Mix.env() == :test do 10 | # Clear the console before each test run 11 | config :mix_test_watch, clear: true 12 | end 13 | 14 | # # Import environment specific config. This must remain at the bottom of this 15 | # # file so it overrides the configuration defined above. 16 | # import_config "#{Mix.env()}.exs" 17 | -------------------------------------------------------------------------------- /lib/typed_struct.ex: -------------------------------------------------------------------------------- 1 | defmodule TypedStruct do 2 | @external_resource "README.md" 3 | @moduledoc "README.md" 4 | |> File.read!() 5 | |> String.split("") 6 | |> Enum.fetch!(1) 7 | 8 | @accumulating_attrs [ 9 | :ts_plugins, 10 | :ts_plugin_fields, 11 | :ts_fields, 12 | :ts_types, 13 | :ts_enforce_keys 14 | ] 15 | 16 | @attrs_to_delete [:ts_enforce? | @accumulating_attrs] 17 | 18 | @doc false 19 | defmacro __using__(_) do 20 | quote do 21 | import TypedStruct, only: [typedstruct: 1, typedstruct: 2] 22 | end 23 | end 24 | 25 | @doc """ 26 | Defines a typed struct. 27 | 28 | Inside a `typedstruct` block, each field is defined through the `field/2` 29 | macro. 30 | 31 | ## Options 32 | 33 | * `enforce` - if set to true, sets `enforce: true` to all fields by default. 34 | This can be overridden by setting `enforce: false` or a default value on 35 | individual fields. 36 | * `opaque` - if set to true, creates an opaque type for the struct. 37 | * `module` - if set, creates the struct in a submodule named `module`. 38 | 39 | ## Examples 40 | 41 | defmodule MyStruct do 42 | use TypedStruct 43 | 44 | typedstruct do 45 | field :field_one, String.t() 46 | field :field_two, integer(), enforce: true 47 | field :field_three, boolean(), enforce: true 48 | field :field_four, atom(), default: :hey 49 | end 50 | end 51 | 52 | The following is an equivalent using the *enforce by default* behaviour: 53 | 54 | defmodule MyStruct do 55 | use TypedStruct 56 | 57 | typedstruct enforce: true do 58 | field :field_one, String.t(), enforce: false 59 | field :field_two, integer() 60 | field :field_three, boolean() 61 | field :field_four, atom(), default: :hey 62 | end 63 | end 64 | 65 | You can create the struct in a submodule instead: 66 | 67 | defmodule MyModule do 68 | use TypedStruct 69 | 70 | typedstruct module: Struct do 71 | field :field_one, String.t() 72 | field :field_two, integer(), enforce: true 73 | field :field_three, boolean(), enforce: true 74 | field :field_four, atom(), default: :hey 75 | end 76 | end 77 | """ 78 | defmacro typedstruct(opts \\ [], do: block) do 79 | ast = TypedStruct.__typedstruct__(block, opts) 80 | 81 | case opts[:module] do 82 | nil -> 83 | quote do 84 | # Create a lexical scope. 85 | (fn -> unquote(ast) end).() 86 | end 87 | 88 | module -> 89 | quote do 90 | defmodule unquote(module) do 91 | unquote(ast) 92 | end 93 | end 94 | end 95 | end 96 | 97 | @doc false 98 | def __typedstruct__(block, opts) do 99 | quote do 100 | Enum.each(unquote(@accumulating_attrs), fn attr -> 101 | Module.register_attribute(__MODULE__, attr, accumulate: true) 102 | end) 103 | 104 | Module.put_attribute(__MODULE__, :ts_enforce?, unquote(!!opts[:enforce])) 105 | @before_compile {unquote(__MODULE__), :__plugin_callbacks__} 106 | 107 | import TypedStruct 108 | unquote(block) 109 | 110 | @enforce_keys @ts_enforce_keys 111 | defstruct @ts_fields 112 | 113 | TypedStruct.__type__(@ts_types, unquote(opts)) 114 | end 115 | end 116 | 117 | @doc false 118 | defmacro __type__(types, opts) do 119 | if Keyword.get(opts, :opaque, false) do 120 | quote bind_quoted: [types: types] do 121 | @opaque t() :: %__MODULE__{unquote_splicing(types)} 122 | end 123 | else 124 | quote bind_quoted: [types: types] do 125 | @type t() :: %__MODULE__{unquote_splicing(types)} 126 | end 127 | end 128 | end 129 | 130 | @doc """ 131 | Registers a plugin for the currently defined struct. 132 | 133 | ## Example 134 | 135 | typedstruct do 136 | plugin MyPlugin 137 | 138 | field :a_field, String.t() 139 | end 140 | 141 | For more information on how to define your own plugins, please see 142 | `TypedStruct.Plugin`. To use a third-party plugin, please refer directly to 143 | its documentation. 144 | """ 145 | defmacro plugin(plugin, opts \\ []) do 146 | quote do 147 | Module.put_attribute( 148 | __MODULE__, 149 | :ts_plugins, 150 | {unquote(plugin), unquote(opts)} 151 | ) 152 | 153 | require unquote(plugin) 154 | unquote(plugin).init(unquote(opts)) 155 | end 156 | end 157 | 158 | @doc """ 159 | Defines a field in a typed struct. 160 | 161 | ## Example 162 | 163 | # A field named :example of type String.t() 164 | field :example, String.t() 165 | 166 | ## Options 167 | 168 | * `default` - sets the default value for the field 169 | * `enforce` - if set to true, enforces the field and makes its type 170 | non-nullable 171 | """ 172 | defmacro field(name, type, opts \\ []) do 173 | quote bind_quoted: [name: name, type: Macro.escape(type), opts: opts] do 174 | TypedStruct.__field__(name, type, opts, __ENV__) 175 | end 176 | end 177 | 178 | @doc false 179 | def __field__(name, type, opts, %Macro.Env{module: mod} = env) 180 | when is_atom(name) do 181 | if mod |> Module.get_attribute(:ts_fields) |> Keyword.has_key?(name) do 182 | raise ArgumentError, "the field #{inspect(name)} is already set" 183 | end 184 | 185 | has_default? = Keyword.has_key?(opts, :default) 186 | enforce_by_default? = Module.get_attribute(mod, :ts_enforce?) 187 | 188 | enforce? = 189 | if is_nil(opts[:enforce]), 190 | do: enforce_by_default? && !has_default?, 191 | else: !!opts[:enforce] 192 | 193 | nullable? = !has_default? && !enforce? 194 | 195 | Module.put_attribute(mod, :ts_fields, {name, opts[:default]}) 196 | Module.put_attribute(mod, :ts_plugin_fields, {name, type, opts, env}) 197 | Module.put_attribute(mod, :ts_types, {name, type_for(type, nullable?)}) 198 | if enforce?, do: Module.put_attribute(mod, :ts_enforce_keys, name) 199 | end 200 | 201 | def __field__(name, _type, _opts, _env) do 202 | raise ArgumentError, "a field name must be an atom, got #{inspect(name)}" 203 | end 204 | 205 | # Makes the type nullable if the key is not enforced. 206 | defp type_for(type, false), do: type 207 | defp type_for(type, _), do: quote(do: unquote(type) | nil) 208 | 209 | @doc false 210 | defmacro __plugin_callbacks__(%Macro.Env{module: module}) do 211 | plugins = Module.get_attribute(module, :ts_plugins) 212 | fields = Module.get_attribute(module, :ts_plugin_fields) |> Enum.reverse() 213 | 214 | Enum.each(unquote(@attrs_to_delete), &Module.delete_attribute(module, &1)) 215 | 216 | fields_block = 217 | for {plugin, plugin_opts} <- plugins, 218 | {name, type, field_opts, env} <- fields do 219 | plugin.field(name, type, field_opts ++ plugin_opts, env) 220 | end 221 | 222 | after_definition_block = 223 | for {plugin, plugin_opts} <- plugins do 224 | plugin.after_definition(plugin_opts) 225 | end 226 | 227 | quote do 228 | unquote_splicing(fields_block) 229 | unquote_splicing(after_definition_block) 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /lib/typed_struct/plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule TypedStruct.Plugin do 2 | @moduledoc """ 3 | This module defines the plugin interface for TypedStruct. 4 | 5 | ## Rationale 6 | 7 | Sometimes you may want to define helpers on your structs, for all their fields 8 | or for the struct as a whole. This plugin interface lets you integrate your 9 | own needs with TypedStruct. 10 | 11 | ## Plugin definition 12 | 13 | A TypedStruct plugin is a module that implements `TypedStruct.Plugin`. Three 14 | callbacks are available to you for injecting code at different steps: 15 | 16 | * `c:init/1` lets you inject code where the `TypedStruct.plugin/2` macro is 17 | called, 18 | * `c:field/4` lets you inject code on each field definition, 19 | * `c:after_definition/1` lets you insert code after the struct and its type 20 | have been defined. 21 | 22 | `use`-ing this module will inject default implementations of all 23 | three, so you only have to implement those you care about. 24 | 25 | ### Example 26 | 27 | As an example, let’s define a plugin that allows users to add an optional 28 | description to their structs and fields. This plugin also takes an `upcase` 29 | option. If set to `true`, all the descriptions are then upcased. It would be 30 | used this way: 31 | 32 | defmodule MyStruct do 33 | use TypedStruct 34 | 35 | typedstruct do 36 | # We import the plugin with the upcase option set to `true`. 37 | plugin DescribedStruct, upcase: true 38 | 39 | # We can now set a description for the struct. 40 | description "My struct" 41 | 42 | # We can also set a description on a field. 43 | field :a_field, String.t(), description: "A field" 44 | field :second_field, boolean() 45 | end 46 | end 47 | 48 | Once compiled, we would optain: 49 | 50 | iex> MyStruct.struct_description() 51 | "MY STRUCT" 52 | iex> MyStruct.field_description(:a_field) 53 | "A FIELD" 54 | 55 | Follows the plugin definition: 56 | 57 | defmodule DescribedStruct do 58 | use TypedStruct.Plugin 59 | 60 | # The init macro lets you inject code where the plugin macro is called. 61 | # You can think a bit of it like a `use` but for the scope of the 62 | # typedstruct block. 63 | @impl true 64 | @spec init(keyword()) :: Macro.t() 65 | defmacro init(opts) do 66 | quote do 67 | # Let’s import our custom `description` macro defined below so our 68 | # users can use it when defining their structs. 69 | import TypedStructDemoPlugin, only: [description: 1] 70 | 71 | # Let’s also store the upcase option in an attribute so we can 72 | # access it from the code injected by our `description/1` macro. 73 | @upcase unquote(opts)[:upcase] 74 | end 75 | end 76 | 77 | # This is a public macro our users can call in their typedstruct blocks. 78 | @spec description(String.t()) :: Macro.t() 79 | defmacro description(description) do 80 | quote do 81 | # Here we simply evaluate the result of __description__/2. We need 82 | # this indirection to be able to use @upcase after is has been 83 | # evaluated, but still in the code generation process. This way, we 84 | # can upcase the strings *at build time* if needed. It’s just a tiny 85 | # refinement :-) 86 | Module.eval_quoted( 87 | __MODULE__, 88 | TypedStructDemoPlugin.__description__(__MODULE__, unquote(description)) 89 | ) 90 | end 91 | end 92 | 93 | @spec __description__(module(), String.t()) :: Macro.t() 94 | def __description__(module, description) do 95 | # Maybe upcase the description at build time. 96 | description = 97 | module 98 | |> Module.get_attribute(:upcase) 99 | |> maybe_upcase(description) 100 | 101 | quote do 102 | # Let’s just generate a constant function that returns the 103 | # description. 104 | def struct_description, do: unquote(description) 105 | end 106 | end 107 | 108 | # The field callback is called for each field defined in the typedstruct 109 | # block. You get exactly what the user has passed to the field macro, 110 | # plus options from every plugin init. The `env` variable contains the 111 | # environment as it stood at the moment of the corresponding 112 | # `TypedStruct.field/3` call. 113 | @impl true 114 | @spec field(atom(), any(), keyword(), Macro.Env.t()) :: Macro.t() 115 | def field(name, _type, opts, _env) do 116 | # Same as for the struct description, we want to upcase at build time 117 | # if necessary. As we do not have access to the module here, we cannot 118 | # access @upcase. This is not an issue since the option is 119 | # automatically added to `opts`, in addition to the options passed to 120 | # the field macro. 121 | description = maybe_upcase(opts[:upcase], opts[:description] || "") 122 | 123 | quote do 124 | # We define a clause matching the field name returning its optional 125 | # description. 126 | def field_description(unquote(name)), do: unquote(description) 127 | end 128 | end 129 | 130 | defp maybe_upcase(true, description), do: String.upcase(description) 131 | defp maybe_upcase(_, description), do: description 132 | 133 | # The after_definition callback is called after the struct and its type 134 | # have been defined, at the end of the `typedstruct` block. 135 | @impl true 136 | @spec after_definition(opts :: keyword()) :: Macro.t() 137 | def after_definition(_opts) do 138 | quote do 139 | # Here we just clean the @upcase attribute so that it does not 140 | # pollute our user’s modules. 141 | Module.delete_attribute(__MODULE__, :upcase) 142 | end 143 | end 144 | end 145 | """ 146 | 147 | @doc """ 148 | Injects code where `TypedStruct.plugin/2` is called. 149 | """ 150 | @macrocallback init(opts :: keyword()) :: Macro.t() 151 | 152 | @doc deprecated: "Use TypedStruct.Plugin.field/4 instead" 153 | @callback field(name :: atom(), type :: any(), opts :: keyword()) :: 154 | Macro.t() 155 | 156 | @doc """ 157 | Injects code after each field definition. 158 | 159 | `name` and `type` are the exact values passed to the `TypedStruct.field/3` 160 | macro in the `typedstruct` block. `opts` is the concatenation of the options 161 | passed to the `field` macro and those from the plugin init. `env` is the 162 | environment at the time of each field definition. 163 | """ 164 | @callback field( 165 | name :: atom(), 166 | type :: any(), 167 | opts :: keyword(), 168 | env :: Macro.Env.t() 169 | ) :: 170 | Macro.t() 171 | 172 | @doc """ 173 | Injects code after the struct and its type have been defined. 174 | """ 175 | @callback after_definition(opts :: keyword()) :: Macro.t() 176 | 177 | @optional_callbacks [field: 3, field: 4] 178 | 179 | @doc false 180 | defmacro __using__(_opts) do 181 | quote do 182 | @behaviour TypedStruct.Plugin 183 | @before_compile {unquote(__MODULE__), :maybe_define_field_4} 184 | 185 | @doc false 186 | defmacro init(_opts), do: nil 187 | 188 | @doc false 189 | def after_definition(_opts), do: nil 190 | 191 | defoverridable init: 1, after_definition: 1 192 | end 193 | end 194 | 195 | @doc false 196 | defmacro maybe_define_field_4(env) do 197 | case {Module.defines?(env.module, {:field, 3}, :def), 198 | Module.defines?(env.module, {:field, 4}, :def)} do 199 | {false, false} -> 200 | # If none is present, let’s define a default implementation for field/4. 201 | quote do 202 | @doc false 203 | def field(_name, _type, _opts, _env), do: nil 204 | end 205 | 206 | {false, true} -> 207 | # If field/4 is present, allright. 208 | nil 209 | 210 | {true, false} -> 211 | # If field/3 is present, let’s define field/4 from it for compatibility. 212 | IO.warn([ 213 | Atom.to_string(env.module), 214 | " defines field/3, which is deprecated. Please use field/4 instead" 215 | ]) 216 | 217 | quote do 218 | @doc false 219 | def field(name, type, opts, _env) do 220 | field(name, type, opts) 221 | end 222 | end 223 | 224 | {true, true} -> 225 | # If both are present, this is an issue. 226 | IO.warn([ 227 | Atom.to_string(env.module), 228 | " defines both field/3 and field/4 callbacks.", 229 | " Only field/4 will be invoked" 230 | ]) 231 | end 232 | end 233 | end 234 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule TypedStruct.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.3.0" 5 | @repo_url "https://github.com/ejpcmac/typed_struct" 6 | 7 | def project do 8 | [ 9 | app: :typed_struct, 10 | version: @version, 11 | elixir: "~> 1.6", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | 15 | # Tools 16 | dialyzer: dialyzer(), 17 | test_coverage: [tool: ExCoveralls], 18 | preferred_cli_env: cli_env(), 19 | 20 | # Docs 21 | name: "TypedStruct", 22 | docs: [ 23 | extras: [ 24 | "README.md": [title: "Overview"], 25 | "CHANGELOG.md": [title: "Changelog"], 26 | "CONTRIBUTING.md": [title: "Contributing"], 27 | "LICENSE.md": [title: "License"] 28 | ], 29 | main: "readme", 30 | source_url: @repo_url, 31 | source_ref: "v#{@version}", 32 | formatters: ["html"] 33 | ], 34 | 35 | # Package 36 | package: package(), 37 | description: 38 | "A library for defining structs with a type without writing " <> 39 | "boilerplate code." 40 | ] 41 | end 42 | 43 | defp deps do 44 | [ 45 | # Development and test dependencies 46 | {:ex_check, "~> 0.14.0", only: :dev, runtime: false}, 47 | {:credo, "~> 1.0", only: :dev, runtime: false}, 48 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 49 | {:excoveralls, ">= 0.0.0", only: :test, runtime: false}, 50 | {:mix_test_watch, ">= 0.0.0", only: :test, runtime: false}, 51 | {:ex_unit_notifier, ">= 0.0.0", only: :test, runtime: false}, 52 | 53 | # Project dependencies 54 | 55 | # Documentation dependencies 56 | {:ex_doc, ">= 0.0.0", only: :docs, runtime: false} 57 | ] 58 | end 59 | 60 | # Dialyzer configuration 61 | defp dialyzer do 62 | [ 63 | # Use a custom PLT directory for continuous integration caching. 64 | plt_core_path: System.get_env("PLT_DIR"), 65 | plt_file: plt_file(), 66 | plt_add_deps: :app_tree, 67 | flags: [ 68 | :unmatched_returns, 69 | :error_handling, 70 | :race_conditions 71 | ], 72 | ignore_warnings: ".dialyzer_ignore" 73 | ] 74 | end 75 | 76 | defp plt_file do 77 | case System.get_env("PLT_DIR") do 78 | nil -> nil 79 | plt_dir -> {:no_warn, Path.join(plt_dir, "typed_struct.plt")} 80 | end 81 | end 82 | 83 | defp cli_env do 84 | [ 85 | # Run mix test.watch in `:test` env. 86 | "test.watch": :test, 87 | 88 | # Always run Coveralls Mix tasks in `:test` env. 89 | coveralls: :test, 90 | "coveralls.detail": :test, 91 | "coveralls.html": :test, 92 | 93 | # Use a custom env for docs. 94 | docs: :docs 95 | ] 96 | end 97 | 98 | defp package do 99 | [ 100 | licenses: ["MIT"], 101 | links: %{ 102 | "Changelog" => "https://hexdocs.pm/typed_struct/changelog.html", 103 | "GitHub" => @repo_url 104 | } 105 | ] 106 | end 107 | 108 | # Helper to add a development revision to the version. Do NOT make a call to 109 | # Git this way in a production release!! 110 | def dev do 111 | with {rev, 0} <- 112 | System.cmd("git", ["rev-parse", "--short", "HEAD"], 113 | stderr_to_stdout: true 114 | ), 115 | {status, 0} <- System.cmd("git", ["status", "--porcelain"]) do 116 | status = if status == "", do: "", else: "-dirty" 117 | "-dev+" <> String.trim(rev) <> status 118 | else 119 | _ -> "-dev" 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "credo": {:hex, :credo, "1.6.3", "0a9f8925dbc8f940031b789f4623fc9a0eea99d3eed600fe831e403eb96c6a83", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1167cde00e6661d740fc54da2ee268e35d3982f027399b64d3e2e83af57a1180"}, 5 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.19", "de0d033d5ff9fc396a24eadc2fcf2afa3d120841eb3f1004d138cbf9273210e8", [:mix], [], "hexpm", "527ab6630b5c75c3a3960b75844c314ec305c76d9899bb30f71cb85952a9dc45"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"}, 9 | "ex_doc": {:hex, :ex_doc, "0.28.0", "7eaf526dd8c80ae8c04d52ac8801594426ae322b52a6156cd038f30bafa8226f", [:mix], [{:earmark_parser, "~> 1.4.19", [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", "e55cdadf69a5d1f4cfd8477122ebac5e1fadd433a8c1022dafc5025e48db0131"}, 10 | "ex_unit_notifier": {:hex, :ex_unit_notifier, "1.2.0", "73ced2ecee0f2da0705e372c21ce61e4e5d927ddb797f73928e52818b9cc1754", [:mix], [], "hexpm", "f38044c9d50de68ad7f0aec4d781a10d9f1c92c62b36bf0227ec0aaa96aee332"}, 11 | "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"}, 12 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 13 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.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", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 14 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 15 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 16 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 17 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 18 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 19 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 20 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 21 | "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.2", "b99ca56bbce410e9d5ee4f9155a212e942e224e259c7ebbf8f2c86ac21d4fa3c", [:mix], [], "hexpm", "98d51bd64d5f6a2a9c6bb7586ee8129e27dfaab1140b5a4753f24dac0ba27d2f"}, 23 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 24 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 25 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 26 | } 27 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | with pkgs; 4 | 5 | let 6 | inherit (lib) optional optionals; 7 | in 8 | 9 | mkShell { 10 | buildInputs = [ elixir git gitAndTools.gitflow ] 11 | ++ optional stdenv.isLinux libnotify # For ExUnit Notifier on Linux. 12 | ++ optional stdenv.isLinux inotify-tools # For file_system on Linux. 13 | ++ optional stdenv.isDarwin terminal-notifier # For ExUnit Notifier on macOS. 14 | ++ optionals stdenv.isDarwin (with darwin.apple_sdk.frameworks; [ 15 | # For file_system on macOS. 16 | CoreFoundation 17 | CoreServices 18 | ]); 19 | } 20 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.configure(formatters: [ExUnit.CLIFormatter, ExUnitNotifier]) 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /test/typed_struct/plugin_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TypedStruct.PluginTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureIO 5 | 6 | ############################################################################ 7 | ## Test data ## 8 | ############################################################################ 9 | 10 | defmodule TestPlugin do 11 | @moduledoc """ 12 | A TypedStruct plugin for test purpose. 13 | """ 14 | use TypedStruct.Plugin 15 | 16 | # We define here a function just for the purpose of making it available in 17 | # the typedstruct block when using the plugin. 18 | def function_from_plugin, do: true 19 | 20 | @impl true 21 | defmacro init(opts) do 22 | quote do 23 | # Import function_from_plugin/0 for scope tests. 24 | import TestPlugin, only: [function_from_plugin: 0] 25 | 26 | Module.register_attribute( 27 | __MODULE__, 28 | :test_field_list, 29 | accumulate: true 30 | ) 31 | 32 | # Define a function to test code injection in the module. 33 | def function_defined_by_the_plugin, do: true 34 | 35 | # Make the options inspectable. 36 | def plugin_init_options, do: unquote(opts) 37 | end 38 | end 39 | 40 | @impl true 41 | def field(name, _type, opts, _env) do 42 | quote do 43 | # Keep a list of the fields on which it has been called. 44 | Module.put_attribute(__MODULE__, :test_field_list, unquote(name)) 45 | 46 | # Define a function to test code injection in the module. 47 | def function_defined_by_the_plugin_for(unquote(name)), do: unquote(name) 48 | 49 | # Make the options inspectable by field. 50 | def options_for(unquote(name)), do: unquote(opts) 51 | end 52 | end 53 | 54 | @impl true 55 | def after_definition(opts) do 56 | quote do 57 | # Define a function to test code injection in the module. 58 | def function_defined_by_the_plugin_after_definition, do: true 59 | 60 | # Make the options inspectable. 61 | def plugin_after_definition_options, do: unquote(opts) 62 | 63 | # Make the list of fields built by our field/4 callback inspectable. 64 | def test_field_list, do: @test_field_list 65 | 66 | # If @enforce_keys is valid, it means the struct has been defined by 67 | # TypedStruct before calling after_definition/1. 68 | def should_contain_enforced_keys, do: @enforce_keys 69 | end 70 | end 71 | end 72 | 73 | defmodule TestStruct do 74 | @moduledoc """ 75 | A test struct using our test plugin. 76 | """ 77 | use TypedStruct 78 | 79 | typedstruct do 80 | plugin TestPlugin, global: :global_value 81 | 82 | field :a_field, atom(), local: :local_value 83 | field :another_field, String.t(), enforce: true 84 | 85 | # This is ugly to define something else in the typedstruct block, but 86 | # let’s use it to check if the plugin’s init/1 macro make code available 87 | # to use here. 88 | def call_function_from_plugin, do: function_from_plugin() 89 | end 90 | end 91 | 92 | ############################################################################ 93 | ## Standard cases ## 94 | ############################################################################ 95 | 96 | test "all callbacks are optional to define" do 97 | defmodule EmptyPlugin do 98 | use TypedStruct.Plugin 99 | end 100 | end 101 | 102 | test "quoted code in init/1 is available in the typedstruct block" do 103 | assert TestStruct.call_function_from_plugin() 104 | end 105 | 106 | test "init/1 can inject code in the compiled module" do 107 | assert TestStruct.function_defined_by_the_plugin() 108 | end 109 | 110 | test "init/1 is called with the options passed to plugin/2" do 111 | assert TestStruct.plugin_init_options() == [global: :global_value] 112 | end 113 | 114 | test "field/4 is called on each field declaration" do 115 | assert TestStruct.test_field_list() == [:another_field, :a_field] 116 | end 117 | 118 | test "field/4 can inject code in the compiled module" do 119 | assert TestStruct.function_defined_by_the_plugin_for(:a_field) == :a_field 120 | end 121 | 122 | test "field/4 is called with both local and global options" do 123 | assert TestStruct.options_for(:a_field) == [ 124 | local: :local_value, 125 | global: :global_value 126 | ] 127 | 128 | assert TestStruct.options_for(:another_field) == [ 129 | enforce: true, 130 | global: :global_value 131 | ] 132 | end 133 | 134 | test "after_definition/1 is called after the struct has been defined" do 135 | assert TestStruct.should_contain_enforced_keys() == [:another_field] 136 | end 137 | 138 | test "after_definition/1 is called with the options passed to plugin/2" do 139 | assert TestStruct.plugin_after_definition_options() == [ 140 | global: :global_value 141 | ] 142 | end 143 | 144 | test "after_definition/1 can inject code in the compiled module" do 145 | assert TestStruct.function_defined_by_the_plugin_after_definition() 146 | end 147 | 148 | ############################################################################ 149 | ## Problems ## 150 | ############################################################################ 151 | 152 | test "the code inserted by init/1 is scoped to the typedstruct block" do 153 | assert_raise CompileError, 154 | ~r"undefined function function_from_plugin/0", 155 | fn -> 156 | defmodule UseImportedFunctionOutsideOfBlock do 157 | use TypedStruct 158 | 159 | typedstruct do 160 | # TestPlugin.init/1 imports function_from_plugin/0. 161 | plugin TestPlugin 162 | end 163 | 164 | # function_from_plugin/0 must not be available here. 165 | def call_function_from_plugin, do: function_from_plugin() 166 | end 167 | end 168 | end 169 | 170 | test "defining field/3 emits a deprecation warning" do 171 | assert capture_io(:stderr, fn -> 172 | defmodule PluginWithField3 do 173 | use TypedStruct.Plugin 174 | 175 | def field(_name, _type, _opts), do: nil 176 | end 177 | end) =~ "PluginWithField3 defines field/3, which is deprecated." 178 | end 179 | 180 | test "defining both field/3 and field/4 emits a compilation warning" do 181 | assert capture_io(:stderr, fn -> 182 | defmodule PluginWithField3And4 do 183 | use TypedStruct.Plugin 184 | 185 | def field(_name, _type, _opts), do: nil 186 | def field(_name, _type, _opts, _env), do: nil 187 | end 188 | end) =~ "PluginWithField3And4 defines both field/3 and field/4" 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /test/typed_struct/plugin_type_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TypedStruct.PluginEnvTest do 2 | @moduledoc """ 3 | Test the the env argument in the field/4 plugin callback. 4 | """ 5 | 6 | use ExUnit.Case 7 | 8 | ############################################################################ 9 | ## Test data ## 10 | ############################################################################ 11 | 12 | defmodule TestPlugin do 13 | use TypedStruct.Plugin 14 | 15 | @impl true 16 | def field(name, type, _opts, env) do 17 | module = module_from_type(type, env) 18 | 19 | quote do 20 | def get_meaning_of_life(unquote(name)) do 21 | unquote(module).meaning_of_life() 22 | end 23 | end 24 | end 25 | 26 | defp module_from_type(type_ast, env) do 27 | {_ast, module} = 28 | Macro.prewalk(type_ast, nil, fn 29 | {:__aliases__, _meta, _list} = ast, nil -> 30 | mod = Macro.expand(ast, env) 31 | {ast, mod} 32 | 33 | ast, acc -> 34 | {ast, acc} 35 | end) 36 | 37 | module 38 | end 39 | end 40 | 41 | defmodule TestDependency do 42 | def meaning_of_life, do: 42 43 | end 44 | 45 | defmodule TestModule do 46 | alias TestDependency, as: FirstDependency 47 | use TypedStruct 48 | 49 | typedstruct do 50 | plugin TestPlugin 51 | alias TestDependency, as: SecondDependency 52 | field :first, FirstDependency.t() 53 | field :second, SecondDependency.t() 54 | end 55 | end 56 | 57 | ############################################################################ 58 | ## Tests ## 59 | ############################################################################ 60 | 61 | test "the field/4 env includes aliases made prior to typedstruct call" do 62 | assert TestModule.get_meaning_of_life(:first) == 42 63 | end 64 | 65 | test "the field/4 env includes aliases made within typedstruct call" do 66 | assert TestModule.get_meaning_of_life(:second) == 42 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/typed_struct_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TypedStructTest do 2 | use ExUnit.Case 3 | 4 | ############################################################################ 5 | ## Test data ## 6 | ############################################################################ 7 | 8 | # Store the bytecode so we can get information from it. 9 | {:module, _name, bytecode, _exports} = 10 | defmodule TestStruct do 11 | use TypedStruct 12 | 13 | typedstruct do 14 | field :int, integer() 15 | field :string, String.t() 16 | field :string_with_default, String.t(), default: "default" 17 | field :mandatory_int, integer(), enforce: true 18 | end 19 | 20 | def enforce_keys, do: @enforce_keys 21 | end 22 | 23 | {:module, _name, bytecode_opaque, _exports} = 24 | defmodule OpaqueTestStruct do 25 | use TypedStruct 26 | 27 | typedstruct opaque: true do 28 | field :int, integer() 29 | end 30 | end 31 | 32 | defmodule EnforcedTypedStruct do 33 | use TypedStruct 34 | 35 | typedstruct enforce: true do 36 | field :enforced_by_default, term() 37 | field :not_enforced, term(), enforce: false 38 | field :with_default, integer(), default: 1 39 | field :with_false_default, boolean(), default: false 40 | field :with_nil_default, term(), default: nil 41 | end 42 | 43 | def enforce_keys, do: @enforce_keys 44 | end 45 | 46 | defmodule TestModule do 47 | use TypedStruct 48 | 49 | typedstruct module: Struct do 50 | field :field, term() 51 | end 52 | end 53 | 54 | {:module, _name, bytecode_noalias, _exports} = 55 | defmodule TestStructNoAlias do 56 | use TypedStruct 57 | 58 | typedstruct do 59 | field :test, TestModule.TestSubModule.t() 60 | end 61 | end 62 | 63 | @bytecode bytecode 64 | @bytecode_opaque bytecode_opaque 65 | @bytecode_noalias bytecode_noalias 66 | 67 | # Standard struct name used when comparing generated types. 68 | @standard_struct_name TypedStructTest.TestStruct 69 | 70 | ############################################################################ 71 | ## Standard cases ## 72 | ############################################################################ 73 | 74 | test "generates the struct with its defaults" do 75 | assert TestStruct.__struct__() == %TestStruct{ 76 | int: nil, 77 | string: nil, 78 | string_with_default: "default", 79 | mandatory_int: nil 80 | } 81 | end 82 | 83 | test "enforces keys for fields with `enforce: true`" do 84 | assert TestStruct.enforce_keys() == [:mandatory_int] 85 | end 86 | 87 | test "enforces keys by default if `enforce: true` is set at top-level" do 88 | assert :enforced_by_default in EnforcedTypedStruct.enforce_keys() 89 | end 90 | 91 | test "does not enforce keys for fields explicitely setting `enforce: false" do 92 | refute :not_enforced in EnforcedTypedStruct.enforce_keys() 93 | end 94 | 95 | test "does not enforce keys for fields with a default value" do 96 | refute :with_default in EnforcedTypedStruct.enforce_keys() 97 | end 98 | 99 | test "does not enforce keys for fields with a default value set to `false`" do 100 | refute :with_false_default in EnforcedTypedStruct.enforce_keys() 101 | end 102 | 103 | test "does not enforce keys for fields with a default value set to `nil`" do 104 | refute :with_nil_default in EnforcedTypedStruct.enforce_keys() 105 | end 106 | 107 | test "generates a type for the struct" do 108 | # Define a second struct with the type expected for TestStruct. 109 | {:module, _name, bytecode2, _exports} = 110 | defmodule TestStruct2 do 111 | defstruct [:int, :string, :string_with_default, :mandatory_int] 112 | 113 | @type t() :: %__MODULE__{ 114 | int: integer() | nil, 115 | string: String.t() | nil, 116 | string_with_default: String.t(), 117 | mandatory_int: integer() 118 | } 119 | end 120 | 121 | # Get both types and standardise them (remove line numbers and rename 122 | # the second struct with the name of the first one). 123 | type1 = @bytecode |> extract_first_type() |> standardise() 124 | 125 | type2 = 126 | bytecode2 127 | |> extract_first_type() 128 | |> standardise(TypedStructTest.TestStruct2) 129 | 130 | assert type1 == type2 131 | end 132 | 133 | test "generates an opaque type if `opaque: true` is set" do 134 | # Define a second struct with the type expected for TestStruct. 135 | {:module, _name, bytecode_expected, _exports} = 136 | defmodule TestStruct3 do 137 | defstruct [:int] 138 | 139 | @opaque t() :: %__MODULE__{ 140 | int: integer() | nil 141 | } 142 | end 143 | 144 | # Get both types and standardise them (remove line numbers and rename 145 | # the second struct with the name of the first one). 146 | type1 = 147 | @bytecode_opaque 148 | |> extract_first_type(:opaque) 149 | |> standardise(TypedStructTest.OpaqueTestStruct) 150 | 151 | type2 = 152 | bytecode_expected 153 | |> extract_first_type(:opaque) 154 | |> standardise(TypedStructTest.TestStruct3) 155 | 156 | assert type1 == type2 157 | end 158 | 159 | test "generates the struct in a submodule if `module: ModuleName` is set" do 160 | assert TestModule.Struct.__struct__() == %TestModule.Struct{field: nil} 161 | end 162 | 163 | ############################################################################ 164 | ## Problems ## 165 | ############################################################################ 166 | 167 | test "TypedStruct macros are available only in the typedstruct block" do 168 | assert_raise CompileError, ~r"undefined function field/2", fn -> 169 | defmodule ScopeTest do 170 | use TypedStruct 171 | 172 | typedstruct do 173 | field :in_scope, term() 174 | end 175 | 176 | # Let’s try to use field/2 outside the block. 177 | field :out_of_scope, term() 178 | end 179 | end 180 | end 181 | 182 | test "the name of a field must be an atom" do 183 | assert_raise ArgumentError, "a field name must be an atom, got 3", fn -> 184 | defmodule InvalidStruct do 185 | use TypedStruct 186 | 187 | typedstruct do 188 | field 3, integer() 189 | end 190 | end 191 | end 192 | end 193 | 194 | test "it is not possible to add twice a field with the same name" do 195 | assert_raise ArgumentError, "the field :name is already set", fn -> 196 | defmodule InvalidStruct do 197 | use TypedStruct 198 | 199 | typedstruct do 200 | field :name, String.t() 201 | field :name, integer() 202 | end 203 | end 204 | end 205 | end 206 | 207 | test "aliases are properly resolved in types" do 208 | {:module, _name, bytecode_actual, _exports} = 209 | defmodule TestStructWithAlias do 210 | use TypedStruct 211 | 212 | typedstruct do 213 | alias TestModule.TestSubModule 214 | 215 | field :test, TestSubModule.t() 216 | end 217 | end 218 | 219 | # Get both types and standardise them (remove line numbers and rename 220 | # the second struct with the name of the first one). 221 | type1 = 222 | @bytecode_noalias 223 | |> extract_first_type() 224 | |> standardise(TypedStructTest.TestStructNoAlias) 225 | 226 | type2 = 227 | bytecode_actual 228 | |> extract_first_type() 229 | |> standardise(TypedStructTest.TestStructWithAlias) 230 | 231 | assert type1 == type2 232 | end 233 | 234 | ############################################################################ 235 | ## Helpers ## 236 | ############################################################################ 237 | 238 | # Extracts the first type from a module. 239 | defp extract_first_type(bytecode, type_keyword \\ :type) do 240 | case Code.Typespec.fetch_types(bytecode) do 241 | {:ok, types} -> Keyword.get(types, type_keyword) 242 | _ -> nil 243 | end 244 | end 245 | 246 | # Standardises a type (removes line numbers and renames the struct to the 247 | # standard struct name). 248 | defp standardise(type_info, struct \\ @standard_struct_name) 249 | 250 | defp standardise({name, type, params}, struct) when is_tuple(type), 251 | do: {name, standardise(type, struct), params} 252 | 253 | defp standardise({:type, _, type, params}, struct), 254 | do: {:type, :line, type, standardise(params, struct)} 255 | 256 | defp standardise({:remote_type, _, params}, struct), 257 | do: {:remote_type, :line, standardise(params, struct)} 258 | 259 | defp standardise({:atom, _, struct}, struct), 260 | do: {:atom, :line, @standard_struct_name} 261 | 262 | defp standardise({type, _, litteral}, _struct), 263 | do: {type, :line, litteral} 264 | 265 | defp standardise(list, struct) when is_list(list), 266 | do: Enum.map(list, &standardise(&1, struct)) 267 | end 268 | --------------------------------------------------------------------------------