diff options
author | José Valim <jose.valim@dashbit.co> | 2022-07-20 16:32:54 +0200 |
---|---|---|
committer | José Valim <jose.valim@dashbit.co> | 2022-07-20 16:34:15 +0200 |
commit | 4056be1d4f3a5cc16f97b937d438f07f2a062fd1 (patch) | |
tree | ab37a81d2267aae3a2ec63d138a66d4c7e74af1e /lib/elixir | |
parent | 85d5dc5793543292ef9d3b0b5804744f4c1818cb (diff) | |
download | elixir-4056be1d4f3a5cc16f97b937d438f07f2a062fd1.tar.gz |
Add @after_verify callback
@after_verify hooks are invoked right after the current module is verified for
undefined functions, deprecations, etc. A module is always verified after
it is compiled. In Mix projects, a module is also verified when any of its
runtime dependencies change. Therefore this is useful to perform verification
of the current module while avoiding compile-time dependencies.
Here are some sample use cases:
* Ecto can use this validate associations consistently and effectively
* Phoenix can use this to verify routes
* Surface can use this to verify component attributes
Diffstat (limited to 'lib/elixir')
-rw-r--r-- | lib/elixir/lib/code.ex | 2 | ||||
-rw-r--r-- | lib/elixir/lib/module.ex | 177 | ||||
-rw-r--r-- | lib/elixir/lib/module/parallel_checker.ex | 91 | ||||
-rw-r--r-- | lib/elixir/src/elixir_module.erl | 4 | ||||
-rw-r--r-- | lib/elixir/test/elixir/module/types/integration_test.exs | 23 | ||||
-rw-r--r-- | lib/elixir/test/elixir/module_test.exs | 12 |
6 files changed, 183 insertions, 126 deletions
diff --git a/lib/elixir/lib/code.ex b/lib/elixir/lib/code.ex index 767cd3ec4..8491e059c 100644 --- a/lib/elixir/lib/code.ex +++ b/lib/elixir/lib/code.ex @@ -152,7 +152,7 @@ defmodule Code do of keys to traverse in the application environment and `return` is either `{:ok, value}` or `:error`. - * `{:on_module, bytecode, :none}` - (since v1.11.0) traced whenever a module + * `{:on_module, bytecode, _ignore}` - (since v1.11.0) traced whenever a module is defined. This is equivalent to the `@after_compile` callback and invoked after any `@after_compile` in the given module. The third element is currently `:none` but it may provide more metadata in the future. It is best to ignore diff --git a/lib/elixir/lib/module.ex b/lib/elixir/lib/module.ex index 1d807de5b..92c4d801f 100644 --- a/lib/elixir/lib/module.ex +++ b/lib/elixir/lib/module.ex @@ -22,6 +22,12 @@ defmodule Module do Accepts a module or a `{module, function_name}`. See the "Compile callbacks" section below. + ### `@after_verify` (since v1.14.0) + + A hook that will be invoked right after the current module is verified for + undefined functions, deprecations, etc. Accepts a module or a `{module, function_name}`. + See the "Compile callbacks" section below. + ### `@before_compile` A hook that will be invoked before the module is compiled. @@ -128,7 +134,7 @@ defmodule Module do Multiple uses of `@compile` will accumulate instead of overriding previous ones. See the "Compile options" section below. - ### `@deprecated` (since 1.6.0) + ### `@deprecated` (since v1.6.0) Provides the deprecation reason for a function. For example: @@ -238,8 +244,7 @@ defmodule Module do end end - For the list of supported warnings, see - [`:dialyzer` module](`:dialyzer`). + For the list of supported warnings, see [`:dialyzer` module](`:dialyzer`). Multiple uses of `@dialyzer` will accumulate instead of overriding previous ones. @@ -300,17 +305,59 @@ defmodule Module do A hook that will be invoked when each function or macro in the current module is defined. Useful when annotating functions. - Accepts a module or a `{module, function_name}` tuple. See the - "Compile callbacks" section below. + Accepts a module or a `{module, function_name}` tuple. The function + must take 6 arguments: + + * the module environment + * the kind of the function/macro: `:def`, `:defp`, `:defmacro`, or `:defmacrop` + * the function/macro name + * the list of quoted arguments + * the list of quoted guards + * the quoted function body + + If the function/macro being defined has multiple clauses, the hook will + be called for each clause. + + Unlike other hooks, `@on_definition` will only invoke functions and + never macros. This is to avoid `@on_definition` callbacks from + redefining functions that have just been defined in favor of more + explicit approaches. + + When just a module is provided, the function is assumed to be + `__on_definition__/6`. + + #### Example + + defmodule Hooks do + def on_def(_env, kind, name, args, guards, body) do + IO.puts("Defining #{kind} named #{name} with args:") + IO.inspect(args) + IO.puts("and guards") + IO.inspect(guards) + IO.puts("and body") + IO.puts(Macro.to_string(body)) + end + end + + defmodule MyModule do + @on_definition {Hooks, :on_def} + + def hello(arg) when is_binary(arg) or is_list(arg) do + "Hello" <> to_string(arg) + end + + def hello(_) do + :ok + end + end ### `@on_load` A hook that will be invoked whenever the module is loaded. - Accepts the function name (as an atom) of a function in the current module or - `{function_name, 0}` tuple where `function_name` is the name of a function in - the current module. The function must have an arity of 0 (no arguments). If - the function does not return `:ok`, the loading of the module will be aborted. + Accepts the function name (as an atom) of a function in the current module. + The function must have an arity of 0 (no arguments). If the function does + not return `:ok`, the loading of the module will be aborted. For example: defmodule MyModule do @@ -380,36 +427,14 @@ defmodule Module do ## Compile callbacks - There are three callbacks that are invoked when functions are defined, - as well as before and immediately after the module bytecode is generated. - - ### `@after_compile` - - A hook that will be invoked right after the current module is compiled. - - Accepts a module or a `{module, function_name}` tuple. The function - must take two arguments: the module environment and its bytecode. - When just a module is provided, the function is assumed to be - `__after_compile__/2`. - - Callbacks will run in the order they are registered. - - `Module` functions expecting not yet compiled modules (such as `definitions_in/1`) - are still available at the time `@after_compile` is invoked. - - #### Example - - defmodule MyModule do - @after_compile __MODULE__ - - def __after_compile__(env, _bytecode) do - IO.inspect(env) - end - end + There are three compilation callbacks, invoked in this order: + `@before_compile`, `@after_compile`, and `@after_verify`. + They are described next. ### `@before_compile` - A hook that will be invoked before the module is compiled. + A hook that will be invoked before the module is compiled. This is + often used to change how the current module is being compiled. Accepts a module or a `{module, function_or_macro_name}` tuple. The function/macro must take one argument: the module environment. If @@ -425,9 +450,8 @@ defmodule Module do callback and it will be made concrete one last time after all callbacks run. - *Note*: unlike `@after_compile`, the callback function/macro must - be placed in a separate module (because when the callback is invoked, - the current module does not yet exist). + *Note*: the callback function/macro must be placed in a separate module + (because when the callback is invoked, the current module does not yet exist). #### Example @@ -446,53 +470,54 @@ defmodule Module do B.hello() #=> "world" - ### `@on_definition` + ### `@after_compile` - A hook that will be invoked when each function or macro in the current - module is defined. Useful when annotating functions. + A hook that will be invoked right after the current module is compiled. Accepts a module or a `{module, function_name}` tuple. The function - must take 6 arguments: - - * the module environment - * the kind of the function/macro: `:def`, `:defp`, `:defmacro`, or `:defmacrop` - * the function/macro name - * the list of quoted arguments - * the list of quoted guards - * the quoted function body - - If the function/macro being defined has multiple clauses, the hook will - be called for each clause. + must take two arguments: the module environment and its bytecode. + When just a module is provided, the function is assumed to be + `__after_compile__/2`. - Unlike other hooks, `@on_definition` will only invoke functions and - never macros. This is to avoid `@on_definition` callbacks from - redefining functions that have just been defined in favor of more - explicit approaches. + Callbacks will run in the order they are registered. - When just a module is provided, the function is assumed to be - `__on_definition__/6`. + `Module` functions expecting not yet compiled modules (such as `definitions_in/1`) + are still available at the time `@after_compile` is invoked. #### Example - defmodule Hooks do - def on_def(_env, kind, name, args, guards, body) do - IO.puts("Defining #{kind} named #{name} with args:") - IO.inspect(args) - IO.puts("and guards") - IO.inspect(guards) - IO.puts("and body") - IO.puts(Macro.to_string(body)) + defmodule MyModule do + @after_compile __MODULE__ + + def __after_compile__(env, _bytecode) do + IO.inspect(env) end end - defmodule MyModule do - @on_definition {Hooks, :on_def} + ### `@after_verify` - def hello(arg) when is_binary(arg) or is_list(arg) do - "Hello" <> to_string(arg) - end + A hook that will be invoked right after the current module is verified for + undefined functions, deprecations, etc. A module is always verified after + it is compiled. In Mix projects, a module is also verified when any of its + runtime dependencies change. Therefore this is useful to perform verification + of the current module while avoiding compile-time dependencies. - def hello(_) do + Accepts a module or a `{module, function_name}` tuple. The function + must take one argument: the module name. When just a module is provided, + the function is assumed to be `__after_verify__/2`. + + Callbacks will run in the order they are registered. + + `Module` functions expecting not yet compiled modules are no longer available + at the time `@after_verify` is invoked. + + #### Example + + defmodule MyModule do + @after_verify __MODULE__ + + def __after_verify__(module) do + IO.inspect(module) :ok end end @@ -582,6 +607,9 @@ defmodule Module do after_compile: %{ doc: "A hook that will be invoked right after the current module is compiled." }, + after_verify: %{ + doc: "A hook that will be invoked right after the current module is verified." + }, before_compile: %{ doc: "A hook that will be invoked before the module is compiled." }, @@ -2312,6 +2340,9 @@ defmodule Module do defp preprocess_attribute(:after_compile, atom) when is_atom(atom), do: {atom, :__after_compile__} + defp preprocess_attribute(:after_verify, atom) when is_atom(atom), + do: {atom, :__after_verify__} + defp preprocess_attribute(:on_definition, atom) when is_atom(atom), do: {atom, :__on_definition__} diff --git a/lib/elixir/lib/module/parallel_checker.ex b/lib/elixir/lib/module/parallel_checker.ex index 0530fe718..f2762add3 100644 --- a/lib/elixir/lib/module/parallel_checker.ex +++ b/lib/elixir/lib/module/parallel_checker.ex @@ -58,21 +58,19 @@ defmodule Module.ParallelChecker do receive do {^ref, :cache, ets} -> - loaded_info = + module_map = if is_map(info) do - cache_from_module_map(ets, info) info else - info = File.read!(info) - cache_from_chunk(ets, module, info) - info + info |> File.read!() |> fetch_module_map!(module) end + cache_from_module_map(ets, module_map) send(checker, {ref, :cached}) receive do {^ref, :check} -> - warnings = check_module(module, loaded_info, {checker, ets}) + warnings = check_module(module_map, {checker, ets}) send(pid, {__MODULE__, module, warnings}) send(checker, {__MODULE__, :done}) end @@ -206,35 +204,26 @@ defmodule Module.ParallelChecker do ## Module checking - defp check_module(module, info, cache) do - case extract_definitions(module, info) do - {:ok, module, file, definitions, no_warn_undefined} -> - Module.Types.warnings(module, file, definitions, no_warn_undefined, cache) - |> group_warnings() - |> emit_warnings() - - :error -> - [] - end - end + defp check_module(module_map, cache) do + %{module: module, file: file, compile_opts: compile_opts, definitions: definitions} = + module_map - defp extract_definitions(module, module_map) when is_map(module_map) do no_warn_undefined = - module_map.compile_opts + compile_opts |> extract_no_warn_undefined() |> merge_compiler_no_warn_undefined() - {:ok, module, module_map.file, module_map.definitions, no_warn_undefined} - end + warnings = + module + |> Module.Types.warnings(file, definitions, no_warn_undefined, cache) + |> group_warnings() + |> emit_warnings() - defp extract_definitions(module, binary) when is_binary(binary) do - with {:ok, {_, [debug_info: chunk]}} <- :beam_lib.chunks(binary, [:debug_info]), - {:debug_info_v1, backend, data} <- chunk, - {:ok, module_map} <- backend.debug_info(:elixir_v1, module, data, []) do - extract_definitions(module, module_map) - else - _ -> :error - end + module_map + |> Map.get(:after_verify, []) + |> Enum.each(fn {verify_mod, verify_fun} -> apply(verify_mod, verify_fun, [module]) end) + + warnings end defp extract_no_warn_undefined(compile_opts) do @@ -247,11 +236,8 @@ defmodule Module.ParallelChecker do defp merge_compiler_no_warn_undefined(no_warn_undefined) do case Code.get_compiler_option(:no_warn_undefined) do - :all -> - :all - - list when is_list(list) -> - no_warn_undefined ++ list + :all -> :all + list when is_list(list) -> no_warn_undefined ++ list end end @@ -322,14 +308,8 @@ defmodule Module.ParallelChecker do end defp cache_from_chunk(ets, module) do - case :code.get_object_code(module) do - {^module, binary, _filename} -> cache_from_chunk(ets, module, binary) - _other -> false - end - end - - defp cache_from_chunk(ets, module, binary) do - with {:ok, {_, [{'ExCk', chunk}]}} <- :beam_lib.chunks(binary, ['ExCk']), + with {^module, binary, _filename} <- :code.get_object_code(module), + {:ok, {_, [{'ExCk', chunk}]}} <- :beam_lib.chunks(binary, ['ExCk']), {:elixir_checker_v1, contents} <- :erlang.binary_to_term(chunk) do cache_chunk(ets, module, contents.exports) true @@ -338,16 +318,6 @@ defmodule Module.ParallelChecker do end end - defp cache_from_module_map(ets, map) do - exports = - [{{:__info__, 1}, :def}] ++ - behaviour_exports(map) ++ - definitions_to_exports(map.definitions) - - deprecated = Map.new(map.deprecated) - cache_info(ets, map.module, exports, deprecated, :elixir) - end - defp cache_from_info(ets, module) do if Code.ensure_loaded?(module) do {mode, exports} = info_exports(module) @@ -378,6 +348,23 @@ defmodule Module.ParallelChecker do _ -> %{} end + defp fetch_module_map!(binary, module) when is_binary(binary) do + {:ok, {_, [debug_info: chunk]}} = :beam_lib.chunks(binary, [:debug_info]) + {:debug_info_v1, backend, data} = chunk + {:ok, module_map} = backend.debug_info(:elixir_v1, module, data, []) + module_map + end + + defp cache_from_module_map(ets, map) do + exports = + [{{:__info__, 1}, :def}] ++ + behaviour_exports(map) ++ + definitions_to_exports(map.definitions) + + deprecated = Map.new(map.deprecated) + cache_info(ets, map.module, exports, deprecated, :elixir) + end + defp cache_info(ets, module, exports, deprecated, mode) do Enum.each(exports, fn {{fun, arity}, kind} -> reason = Map.get(deprecated, {fun, arity}) diff --git a/lib/elixir/src/elixir_module.erl b/lib/elixir/src/elixir_module.erl index 2b7084d19..462b8ed73 100644 --- a/lib/elixir/src/elixir_module.erl +++ b/lib/elixir/src/elixir_module.erl @@ -131,6 +131,7 @@ compile(Line, Module, Block, Vars, E) -> RawCompileOpts = bag_lookup_element(DataBag, {accumulate, compile}, 2), CompileOpts = validate_compile_opts(RawCompileOpts, AllDefinitions, Unreachable, File, Line), + AfterVerify = bag_lookup_element(DataBag, {accumulate, after_verify}, 2), ModuleMap = #{ struct => get_struct(DataSet), @@ -141,6 +142,7 @@ compile(Line, Module, Block, Vars, E) -> attributes => Attributes, definitions => AllDefinitions, unreachable => Unreachable, + after_verify => AfterVerify, compile_opts => CompileOpts, deprecated => get_deprecated(DataBag), is_behaviour => is_behaviour(DataBag) @@ -157,6 +159,7 @@ compile(Line, Module, Block, Vars, E) -> elixir_env:trace({on_module, Binary, none}, E), warn_unused_attributes(File, DataSet, DataBag, PersistedAttributes), make_module_available(Module, Binary), + (CheckerInfo == undefined) andalso eval_callbacks(Line, DataBag, after_verify, [Module], NE), {module, Module, Binary, Result} catch error:undef:Stacktrace -> @@ -316,6 +319,7 @@ build(Line, File, Module) -> % {Key, Value, accumulate, TraceLine} {after_compile, [], accumulate, []}, + {after_verify, [], accumulate, []}, {before_compile, [], accumulate, []}, {behaviour, [], accumulate, []}, {compile, [], accumulate, []}, diff --git a/lib/elixir/test/elixir/module/types/integration_test.exs b/lib/elixir/test/elixir/module/types/integration_test.exs index 175163b59..e59311784 100644 --- a/lib/elixir/test/elixir/module/types/integration_test.exs +++ b/lib/elixir/test/elixir/module/types/integration_test.exs @@ -557,6 +557,29 @@ defmodule Module.Types.IntegrationTest do end end + describe "after_verify" do + test "reports functions" do + files = %{ + "a.ex" => """ + defmodule A do + @after_verify __MODULE__ + + def __after_verify__(__MODULE__) do + IO.warn "from after_verify", [] + end + end + """ + } + + warning = """ + warning: from after_verify + + """ + + assert_warnings(files, warning) + end + end + describe "deprecated" do test "reports functions" do files = %{ diff --git a/lib/elixir/test/elixir/module_test.exs b/lib/elixir/test/elixir/module_test.exs index 547af8993..3c7aff406 100644 --- a/lib/elixir/test/elixir/module_test.exs +++ b/lib/elixir/test/elixir/module_test.exs @@ -108,6 +108,18 @@ defmodule ModuleTest do assert_received 42 end + test "supports @after_verify for inlined modules" do + defmodule ModuleTest.AfterVerify do + @after_verify __MODULE__ + + def __after_verify__(ModuleTest.AfterVerify) do + send(self(), ModuleTest.AfterVerify) + end + end + + assert_received ModuleTest.AfterVerify + end + test "in memory modules are tagged as so" do assert :code.which(__MODULE__) == '' end |