├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSES └── MIT.txt ├── README.md ├── bench ├── mod_name_vs_hash.exs └── try_vs_case.exs ├── cliff.toml ├── lib └── defconst.ex ├── mix.exs ├── mix.lock ├── mix.lock.license └── test ├── defconst_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # Used by "mix format" 6 | locals_without_parens = [ 7 | defconst: 2, 8 | defonce: 2 9 | ] 10 | 11 | [ 12 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 13 | locals_without_parens: locals_without_parens, 14 | export: [locals_without_parens: locals_without_parens] 15 | ] 16 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Elixir CI 7 | 8 | on: 9 | push: 10 | branches: [ "master" ] 11 | pull_request: 12 | branches: [ "master" ] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | 20 | name: Build and test 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Elixir 26 | uses: erlef/setup-beam@v1 27 | with: 28 | elixir-version: '1.15.2' # [Required] Define the Elixir version 29 | otp-version: '26.0' # [Required] Define the Erlang/OTP version 30 | - name: Restore dependencies cache 31 | uses: actions/cache@v3 32 | with: 33 | path: deps 34 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 35 | restore-keys: ${{ runner.os }}-mix- 36 | - name: Install dependencies 37 | run: mix deps.get 38 | - name: Run tests 39 | run: mix test 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where third-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | defconst-*.tar 28 | 29 | # Temporary files, for example, from tests. 30 | /tmp/ 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [2.0.0] - 2024-11-12 6 | 7 | ### 🚀 Features 8 | 9 | - Add support for defining private constants 10 | - Use `:persistent_term.get/2` instead of `:persistent_term.get/1` 11 | - Support updating values during hot code reloads 12 | - [**breaking**] Add function that will force recompilation of the defonce value 13 | 14 | ### 📚 Documentation 15 | 16 | - Add more docs and examples (#2) 17 | - Update version in README to 1.0.0 18 | - Add source metadata 19 | - Fix typos in the documentation strings 20 | - Fix typos in README 21 | 22 | ### ⚙️ Miscellaneous Tasks 23 | 24 | - Performance benchmark for try vs case 25 | - Add missing reuse headers for benchmarks and lockfile 26 | - Cleanup benches and move benchee to mix.exs 27 | - Setup GitHub Actions 28 | - Lower required Elixir version 29 | 30 | ## [1.0.0] - 2024-05-03 31 | 32 | ### 🚀 Features 33 | 34 | - Add basic functionality of defconst and defonce 35 | 36 | ### ⚙️ Miscellaneous Tasks 37 | 38 | - Rename from defconst to defconstant to avoid name clash 39 | 40 | 41 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # defconstant 8 | 9 | Helper macros for defining constants in Elixir code 10 | 11 | ## Installation 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:defconstant, "~> 2.0.0"} 17 | ] 18 | end 19 | ``` 20 | 21 | Optionally add `:defconstant` to your `.formatter.exs` to have `defconst` 22 | formatted without parens: 23 | 24 | ``` 25 | [ 26 | import_deps: [ 27 | :defconstant, 28 | ... 29 | ], 30 | ... 31 | ] 32 | ``` 33 | 34 | ## Usage 35 | 36 | This library provides 2 macros: 37 | 38 | - `defconst` - provided body will be evaluated *at compile* time 39 | - `defonce` - provided body will be evaluated *at runtime*, but only once. After 40 | that it will be cached and served from cache. 41 | 42 | Both helper macros allows defining only 0-ary functions (functions that take no 43 | arguments). 44 | 45 | For details see the full docs on [hexdocs.pm](https://hexdocs.pm/defconstant/Defconstant.html) 46 | 47 | ### Example usage 48 | 49 | ``` elixir 50 | defmodule Demo.MyConst do 51 | import Defconstant 52 | 53 | defconst the_answer do 54 | 42 55 | end 56 | 57 | # NOTE: For real code you'd use `:math.pi` instead 58 | defconst pi do 59 | 3.14159 60 | end 61 | 62 | defonce calculated_at do 63 | NaiveDateTime.utc_now() 64 | end 65 | 66 | def run_calculations(circumference) do 67 | circle_radius = circumference / 2 * pi() 68 | "radius is #{circle_radius} and was first calculated at #{inspect calculated_at()}" 69 | end 70 | end 71 | ``` 72 | 73 | And the constants can be used from another module (e.g. `Demo.MyConst.the_answer()` will return `42`) 74 | 75 | ## Why 76 | 77 | Elixir and Erlang/OTP don't have true constants. They do have module attributes, 78 | which are often used like constants, but module attributes can be modified so 79 | they aren't truly constants. For example: 80 | 81 | ``` elixir 82 | defmodule ModuleAttributeDemo do 83 | @pi 3.14159 84 | def pi, do: @pi 85 | 86 | @pi 33 87 | def radius(circumference), do: circumference / 2 * @pi 88 | end 89 | ``` 90 | 91 | Calling `ModuleAttributeDemo.radius(5)` will use `33` as the value for `@pi` 92 | instead of `3.14159`. With `defconst` this can be avoided because you will 93 | receive a warning when re-defining a constant. 94 | 95 | ## License 96 | 97 | MIT 98 | -------------------------------------------------------------------------------- /bench/mod_name_vs_hash.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule SUT do 6 | def do_name do 7 | key = {__MODULE__, :try} 8 | 9 | try do 10 | :persistent_term.get(key) 11 | catch 12 | :error, :badarg -> 13 | value = 2137 14 | 15 | :persistent_term.put(key, value) 16 | 17 | value 18 | end 19 | end 20 | 21 | def do_hash do 22 | key = {__MODULE__.module_info(:md5), :try} 23 | 24 | try do 25 | :persistent_term.get(key) 26 | catch 27 | :error, :badarg -> 28 | value = 2137 29 | 30 | :persistent_term.put(key, value) 31 | 32 | value 33 | end 34 | end 35 | end 36 | 37 | Benchee.run(%{ 38 | "name" => &SUT.do_name/0, 39 | "hash" => &SUT.do_hash/0 40 | }) 41 | -------------------------------------------------------------------------------- /bench/try_vs_case.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule SUT do 6 | @key {__MODULE__, :try} 7 | def do_try do 8 | :persistent_term.get(@key) 9 | catch 10 | :error, :badarg -> 11 | value = 2137 12 | 13 | :persistent_term.put(@key, value) 14 | 15 | value 16 | end 17 | 18 | @key {__MODULE__, :default} 19 | def do_default do 20 | case :persistent_term.get(@key, :undefined) do 21 | :undefined -> 22 | 23 | value = 2137 24 | 25 | :persistent_term.put(@key, value) 26 | 27 | value 28 | 29 | value -> value 30 | end 31 | end 32 | end 33 | 34 | Benchee.run(%{ 35 | "try" => &SUT.do_try/0, 36 | "default" => &SUT.do_default/0, 37 | }) 38 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # template for the changelog header 10 | header = """ 11 | # Changelog\n 12 | All notable changes to this project will be documented in this file.\n 13 | """ 14 | # template for the changelog body 15 | # https://keats.github.io/tera/docs/#introduction 16 | body = """ 17 | {% if version %}\ 18 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 19 | {% else %}\ 20 | ## [unreleased] 21 | {% endif %}\ 22 | {% for group, commits in commits | group_by(attribute="group") %} 23 | ### {{ group | striptags | trim | upper_first }} 24 | {% for commit in commits %} 25 | - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ 26 | {% if commit.breaking %}[**breaking**] {% endif %}\ 27 | {{ commit.message | upper_first }}\ 28 | {% endfor %} 29 | {% endfor %}\n 30 | """ 31 | # template for the changelog footer 32 | footer = """ 33 | 34 | """ 35 | # remove the leading and trailing s 36 | trim = true 37 | # postprocessors 38 | postprocessors = [ 39 | # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL 40 | ] 41 | 42 | [git] 43 | # parse the commits based on https://www.conventionalcommits.org 44 | conventional_commits = true 45 | # filter out the commits that are not conventional 46 | filter_unconventional = true 47 | # process each line of a commit as an individual commit 48 | split_commits = false 49 | # regex for preprocessing the commit messages 50 | commit_preprocessors = [ 51 | # Replace issue numbers 52 | #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, 53 | # Check spelling of the commit with https://github.com/crate-ci/typos 54 | # If the spelling is incorrect, it will be automatically fixed. 55 | #{ pattern = '.*', replace_command = 'typos --write-changes -' }, 56 | ] 57 | # regex for parsing and grouping commits 58 | commit_parsers = [ 59 | { message = "^ft", group = "🚀 Features" }, 60 | { message = "^fix", group = "🐛 Bug Fixes" }, 61 | { message = "^doc", group = "📚 Documentation" }, 62 | { message = "^test", group = "🧪 Testing" }, 63 | { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, 64 | { body = ".*security", group = "🛡️ Security" }, 65 | { message = "^revert", group = "◀️ Revert" }, 66 | ] 67 | # filter out the commits that are not matched by commit parsers 68 | filter_commits = false 69 | # sort the tags topologically 70 | topo_order = false 71 | # sort the commits inside sections by oldest/newest order 72 | sort_commits = "oldest" 73 | -------------------------------------------------------------------------------- /lib/defconst.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Defconstant do 6 | @moduledoc """ 7 | Helper functions for defining constant values in your modules. 8 | 9 | ## Usage 10 | 11 | ```elixir 12 | defmodule Foo do 13 | import #{inspect(__MODULE__)} 14 | 15 | defconst comptime do 16 | # This will be evaluated at compile time 17 | Enum.sum([ 18 | 0, 2, 3, 4, 5, 6, 8, 12, 30, 32, 33, 34, 35, 36, 38, 40, 42, 43, 44, 45, 19 | 46, 48, 50, 52, 53, 54, 55, 56, 58, 60, 62, 63, 64, 65, 66, 68, 80, 82, 20 | 83, 84, 85, 86, 88 21 | ]) 22 | end 23 | 24 | defonce runtime do 25 | 2 * 1068 + 1 26 | end 27 | end 28 | ``` 29 | """ 30 | 31 | defguardp is_empty_args(value) when is_atom(value) or value == [] 32 | 33 | @doc """ 34 | Defines function that will be evaluated once, *in compile time*, and will 35 | return computation result. 36 | 37 | Defined function can only be 0-ary. 38 | 39 | Example: 40 | 41 | defmodule MyConstants do 42 | import Defconstant 43 | defconst the_answer, do: 42 44 | 45 | def my_func do 46 | the_answer() * 2 # returns 84 47 | end 48 | end 49 | """ 50 | defmacro defconst(call, opts), do: do_defconst(:def, call, __CALLER__, opts) 51 | 52 | @doc """ 53 | Defines private function that will be evaluated in compile time. 54 | 55 | For details see `defconst/2`. 56 | """ 57 | defmacro defconstp(call, opts), do: do_defconst(:defp, call, __CALLER__, opts) 58 | 59 | defp do_defconst(type, {name, _, args}, _ctx, do: body) when is_empty_args(args) do 60 | fbody = 61 | quote unquote: false do 62 | unquote(result) 63 | end 64 | 65 | quote do 66 | result = unquote(body) 67 | 68 | unquote(type)(unquote(name)(), do: unquote(fbody)) 69 | end 70 | end 71 | 72 | defp do_defconst(type, {name, _, args}, %Macro.Env{} = ctx, _) when length(args) > 0 do 73 | raise CompileError, 74 | description: 75 | "`defconst#{if type == :defp, do: "p", else: ""}` can define only 0-ary functions, tried to define #{name}/#{length(args)}-ary.", 76 | line: ctx.line, 77 | file: ctx.file 78 | end 79 | 80 | @doc """ 81 | Defines function that will be evaluated once, *in runtime*, and will cache the result. 82 | 83 | Defined function can only be 0-ary. 84 | """ 85 | defmacro defonce(call, opts), do: do_defonce(:def, call, __CALLER__, opts) 86 | 87 | @doc """ 88 | Defines private function that will be evaluated once, *in runtime*, and will cache the result. 89 | 90 | For details see `defonce/2`. 91 | """ 92 | defmacro defoncep(call, opts), do: do_defonce(:defp, call, __CALLER__, opts) 93 | 94 | @max_hash 4_294_967_295 95 | 96 | defp do_defonce(type, {name, _, args}, ctx, do: body) when is_atom(args) or args == [] do 97 | body_hash = :erlang.phash2(body, @max_hash) 98 | 99 | if Atom.to_string(name) =~ ~r/[!?]$/ do 100 | raise CompileError, 101 | description: 102 | "`defonce#{if type == :defp, do: "p", else: ""}` cannot end with `!` nor `?` as `#{name}!/0` is used to force recompilation", 103 | line: ctx.line, 104 | file: ctx.file 105 | end 106 | 107 | quote do 108 | unquote(type)(unquote(name)()) do 109 | case :persistent_term.get({__MODULE__, unquote(name)}, :__no_val__) do 110 | {unquote(body_hash), value} -> 111 | value 112 | 113 | _ -> 114 | unquote(:"#{name}!")() 115 | end 116 | end 117 | 118 | @doc unquote(""" 119 | Force recomputation of `#{name}/0` 120 | """) 121 | unquote(type)(unquote(:"#{name}!")()) do 122 | result = unquote(body) 123 | 124 | :persistent_term.put({__MODULE__, unquote(name)}, {unquote(body_hash), result}) 125 | 126 | result 127 | end 128 | end 129 | end 130 | 131 | defp do_defonce(type, {name, _, args}, ctx, _) when length(args) > 0 do 132 | raise CompileError, 133 | description: 134 | "`defonce#{if type == :defp, do: "p", else: ""}` can define only 0-ary functions, tried to define #{name}/#{length(args)}-ary.", 135 | line: ctx.line, 136 | file: ctx.file 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Defconstant.MixProject do 6 | use Mix.Project 7 | 8 | @version "2.0.0" 9 | 10 | def project do 11 | [ 12 | app: :defconstant, 13 | description: "Helper macros for defining constant values in modules", 14 | version: @version, 15 | elixir: "~> 1.15", 16 | start_permanent: Mix.env() == :prod, 17 | package: %{ 18 | licenses: ~W[MIT], 19 | links: %{ 20 | "GitHub" => "https://github.com/hauleth/defconst" 21 | } 22 | }, 23 | deps: [ 24 | {:ex_doc, ">= 0.0.0", only: [:dev]}, 25 | {:benchee, ">= 0.0.0", only: [:dev]} 26 | ], 27 | docs: [ 28 | main: "Defconstant", 29 | source_url: "https://github.com/hauleth/defconst", 30 | source_ref: "v#{@version}", 31 | formatters: ~w[html] 32 | ] 33 | ] 34 | end 35 | 36 | # Run "mix help compile.app" to learn about applications. 37 | def application do 38 | [] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 5 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 6 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 8 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 10 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 11 | } 12 | -------------------------------------------------------------------------------- /mix.lock.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/defconst_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule DefconstantTest do 6 | use ExUnit.Case, async: true 7 | 8 | @subject Defconstant 9 | 10 | doctest @subject 11 | 12 | defp compile(body, opts \\ []) do 13 | name = 14 | opts[:module_name] || Module.concat(__MODULE__, "Test#{System.unique_integer([:positive])}") 15 | 16 | code = 17 | quote do 18 | defmodule unquote(name) do 19 | import unquote(@subject) 20 | 21 | unquote(body) 22 | end 23 | end 24 | 25 | Code.eval_quoted_with_env(code, [], __ENV__) 26 | 27 | name 28 | end 29 | 30 | describe "defconst" do 31 | test "defines function with given name" do 32 | mod = 33 | compile( 34 | quote do 35 | defconst foo do 36 | 1234 37 | end 38 | end 39 | ) 40 | 41 | assert function_exported?(mod, :foo, 0) 42 | end 43 | 44 | test "defined function is called during compilation" do 45 | _mod = 46 | compile( 47 | quote do 48 | defconst foo do 49 | send(unquote(self()), :ping) 50 | :pong 51 | end 52 | end 53 | ) 54 | 55 | assert_received :ping 56 | end 57 | 58 | test "defined function is not called at runtime" do 59 | mod = 60 | compile( 61 | quote do 62 | defconst foo do 63 | send(unquote(self()), :ping) 64 | :pong 65 | end 66 | end 67 | ) 68 | 69 | assert_received :ping 70 | assert mod.foo() == :pong 71 | 72 | refute_received :ping 73 | end 74 | 75 | test "returned value is always the same" do 76 | mod = 77 | compile( 78 | quote do 79 | defconst foo do 80 | :rand.uniform() 81 | end 82 | end 83 | ) 84 | 85 | assert mod.foo() == mod.foo() 86 | end 87 | 88 | test "trying to define non 0-ary function raises" do 89 | assert_raise CompileError, fn -> 90 | compile( 91 | quote do 92 | defconst foo(a, b) do 93 | 2137 94 | end 95 | end 96 | ) 97 | end 98 | end 99 | end 100 | 101 | describe "defconstp" do 102 | test "defines function with given name" do 103 | mod = 104 | compile( 105 | quote do 106 | defconstp foo do 107 | 1234 108 | end 109 | 110 | def call, do: foo() 111 | end 112 | ) 113 | 114 | refute function_exported?(mod, :foo, 0) 115 | end 116 | 117 | test "defined function is called during compilation" do 118 | _mod = 119 | compile( 120 | quote do 121 | defconstp foo do 122 | send(unquote(self()), :ping) 123 | :pong 124 | end 125 | 126 | def call, do: foo() 127 | end 128 | ) 129 | 130 | assert_received :ping 131 | end 132 | 133 | test "defined function is not called at runtime" do 134 | mod = 135 | compile( 136 | quote do 137 | defconstp foo do 138 | send(unquote(self()), :ping) 139 | :pong 140 | end 141 | 142 | def call do 143 | foo() 144 | end 145 | end 146 | ) 147 | 148 | assert_received :ping 149 | assert mod.call() == :pong 150 | 151 | refute_received :ping 152 | end 153 | 154 | test "returned value is always the same" do 155 | mod = 156 | compile( 157 | quote do 158 | defconstp foo do 159 | :rand.uniform() 160 | end 161 | 162 | def call do 163 | foo() 164 | end 165 | end 166 | ) 167 | 168 | assert mod.call() == mod.call() 169 | end 170 | 171 | test "trying to define non 0-ary function raises" do 172 | assert_raise CompileError, fn -> 173 | compile( 174 | quote do 175 | defconstp foo(a, b) do 176 | 2137 177 | end 178 | end 179 | ) 180 | end 181 | end 182 | end 183 | 184 | describe "defonce" do 185 | test "defines function with given name" do 186 | mod = 187 | compile( 188 | quote do 189 | defonce foo do 190 | 1234 191 | end 192 | end 193 | ) 194 | 195 | assert function_exported?(mod, :foo, 0) 196 | end 197 | 198 | test "defined function is called only once" do 199 | mod = 200 | compile( 201 | quote do 202 | defonce foo do 203 | send(unquote(self()), :ping) 204 | :pong 205 | end 206 | end 207 | ) 208 | 209 | refute_received :ping 210 | assert mod.foo() == :pong 211 | 212 | assert_received :ping 213 | assert mod.foo() == :pong 214 | 215 | refute_received :ping 216 | end 217 | 218 | test "returned value is always the same" do 219 | mod = 220 | compile( 221 | quote do 222 | defonce foo() do 223 | :rand.uniform() 224 | end 225 | end 226 | ) 227 | 228 | assert mod.foo() == mod.foo() 229 | end 230 | 231 | test "trying to define non 0-ary function raises" do 232 | assert_raise CompileError, fn -> 233 | compile( 234 | quote do 235 | defonce foo(a, b) do 236 | 2137 237 | end 238 | end 239 | ) 240 | end 241 | end 242 | end 243 | 244 | describe "defoncep" do 245 | test "defines function with given name" do 246 | mod = 247 | compile( 248 | quote do 249 | defoncep foo do 250 | 1234 251 | end 252 | 253 | def call, do: foo() 254 | end 255 | ) 256 | 257 | refute function_exported?(mod, :foo, 0) 258 | end 259 | 260 | test "defined function is called only once" do 261 | mod = 262 | compile( 263 | quote do 264 | defonce foo do 265 | send(unquote(self()), :ping) 266 | :pong 267 | end 268 | 269 | def call, do: foo() 270 | end 271 | ) 272 | 273 | refute_received :ping 274 | assert mod.call() == :pong 275 | 276 | assert_received :ping 277 | assert mod.call() == :pong 278 | 279 | refute_received :ping 280 | end 281 | 282 | test "returned value is always the same" do 283 | mod = 284 | compile( 285 | quote do 286 | defoncep foo() do 287 | :rand.uniform() 288 | end 289 | 290 | def call, do: foo() 291 | end 292 | ) 293 | 294 | assert mod.call() == mod.call() 295 | end 296 | 297 | test "trying to define non 0-ary function raises" do 298 | assert_raise CompileError, fn -> 299 | compile( 300 | quote do 301 | defoncep foo(a, b) do 302 | 2137 303 | end 304 | end 305 | ) 306 | end 307 | end 308 | 309 | test "when replacing module then function is reevaluated" do 310 | mod_name = __MODULE__.TestRecompilation1 311 | 312 | mod = 313 | compile( 314 | quote do 315 | defonce foo do 316 | send(unquote(self()), {:ping, 1}) 317 | {:pong, 1} 318 | end 319 | 320 | def call, do: foo() 321 | end, 322 | module_name: mod_name 323 | ) 324 | 325 | assert mod.call() == {:pong, 1} 326 | assert_received {:ping, 1} 327 | 328 | mod = 329 | compile( 330 | quote do 331 | defonce foo do 332 | send(unquote(self()), {:ping, 2}) 333 | {:pong, 2} 334 | end 335 | 336 | def call, do: foo() 337 | end, 338 | module_name: mod_name 339 | ) 340 | 341 | assert mod.call() == {:pong, 2} 342 | assert_received {:ping, 2} 343 | end 344 | 345 | test "when defonce body stays the same, do not reevaluate" do 346 | mod_name = __MODULE__.TestRecompilation2 347 | 348 | mod = 349 | compile( 350 | quote do 351 | defonce foo do 352 | send(unquote(self()), :ping) 353 | :pong 354 | end 355 | 356 | def call, do: {foo(), 1} 357 | end, 358 | module_name: mod_name 359 | ) 360 | 361 | assert mod.call() == {:pong, 1} 362 | assert_received :ping 363 | 364 | mod = 365 | compile( 366 | quote do 367 | defonce foo do 368 | send(unquote(self()), :ping) 369 | :pong 370 | end 371 | 372 | def call, do: {foo(), 2} 373 | end, 374 | module_name: mod_name 375 | ) 376 | 377 | assert mod.call() == {:pong, 2} 378 | refute_received :ping 379 | end 380 | 381 | test "bang function forces recomputation" do 382 | mod = 383 | compile( 384 | quote do 385 | defonce foo do 386 | send(unquote(self()), :ping) 387 | {:pong, System.unique_integer()} 388 | end 389 | end 390 | ) 391 | 392 | refute_received :ping 393 | 394 | assert {:pong, first} = mod.foo() 395 | assert_received :ping 396 | 397 | assert {:pong, second} = mod.foo!() 398 | assert_received :ping 399 | 400 | assert first != second 401 | end 402 | 403 | test "function name cannot end with exclamation mark (`!`/bang)" do 404 | assert_raise CompileError, fn -> 405 | compile( 406 | quote do 407 | defoncep foo! do 408 | 2137 409 | end 410 | end 411 | ) 412 | end 413 | end 414 | 415 | test "function name cannot end with question mark (`?`)" do 416 | assert_raise CompileError, fn -> 417 | compile( 418 | quote do 419 | defoncep foo? do 420 | 2137 421 | end 422 | end 423 | ) 424 | end 425 | end 426 | end 427 | end 428 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Łukasz Niemier <#@hauleth.dev> 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ExUnit.start() 6 | --------------------------------------------------------------------------------