diff --git a/README.md b/README.md index 9776727..beda2a9 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Or use `-h` or `--help` option: ### Audit a single target - spectrometer audit --github https://github.com/ninenines/cowboy + spectrometer audit --github https://github.com/atomvm/atomvm_packbeam spectrometer audit --hex jsx spectrometer audit --hex cowboy --version 3.1.0 spectrometer audit --dir /path/to/project @@ -150,14 +150,29 @@ Or use `-h` or `--help` option: ### Query function support +Functions can be queried by specific arity, or arity may be omitted. + spectrometer query lists:map spectrometer query lists:map/2 +#### Elixir function support + +Elixir functions can be queried using several formats: + + spectrometer query Elixir.List.keyfind + spectrometer query List.keyfind + spectrometer query Elixir.List.keyfind/4 + spectrometer query List.keyfind/4 + ### List supported functions spectrometer supported spectrometer supported --module gen_server spectrometer supported -m lists + spectrometer supported -m Elixir.List + spectrometer supported -m List + spectrometer supported --ex # Show only Elixir functions + spectrometer supported --erl # Show only Erlang functions ### Regenerate supported functions database diff --git a/priv/supported_functions.data b/priv/supported_functions.data index a9e16c0..b9c1b05 100644 --- a/priv/supported_functions.data +++ b/priv/supported_functions.data @@ -1,9 +1,783 @@ %% Supported AtomVM functions - machine generated, edit with extreme caution. -%% Format: [{module, [{function, arity, platforms, since}]}] -%% Platforms: 'all' or list of platform atoms [esp32, stm32, rp2, emscripten, generic_unix] +%% Format: [{Module, [{Function, Arity, Platforms, Since}]}] +%% Platforms: 'all' or list of supported platform atoms [esp32, stm32, rp2, emscripten, generic_unix] %% Since: binary version string like <<"v0.5.0">> or {unreleased, <<"0.7.x">>} [ + {'Elixir.AVMPort', [ + {call, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {call, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {call, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {open, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {open, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {'Elixir.Access', [ + {fetch, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {fetch, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'fetch!', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'fetch!', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {get, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {get, 3, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {'Elixir.BadArityError', [{message, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.BadBooleanError', [{message, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.BadFunctionError', [ + {message, 1, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.BadMapError', [{message, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.BadStructError', [{message, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.Base', [ + {decode16, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {decode16, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {'decode16!', 1, all, <<118, 48, 46, 54, 46, 53>>}, + {'decode16!', 2, all, <<118, 48, 46, 54, 46, 53>>}, + {decode64, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decode64, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'decode64!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'decode64!', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode16, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {encode16, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {encode64, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode64, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {url_decode64, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {url_decode64, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'url_decode64!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'url_decode64!', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {url_encode64, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {url_encode64, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Bitwise', [ + {'band', 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'band', 2, all, <<118, 48, 46, 54, 46, 48>>}, + {'bnot', 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'bor', 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'bor', 2, all, <<118, 48, 46, 54, 46, 48>>}, + {'bsl', 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'bsl', 2, all, <<118, 48, 46, 54, 46, 48>>}, + {'bsr', 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'bsr', 2, all, <<118, 48, 46, 54, 46, 48>>}, + {'bxor', 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'bxor', 2, all, <<118, 48, 46, 54, 46, 48>>}, + {unquote, 1, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {'Elixir.CaseClauseError', [{message, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.Code', [ + {ensure_compiled, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'ensure_compiled?', 1, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {'Elixir.Collectable', [{into, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.Collectable.List', [{into, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.Collectable.Map', [{into, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.Collectable.MapSet', [{into, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.CondClauseError', [{message, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.Console', [ + {flush, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {print, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {puts, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {puts, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {'Elixir.Counter', [ + {child_spec, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Derivable', [{ok, 1, all, <<118, 48, 46, 55, 46, 48>>}]}, + {'Elixir.Enum', [ + {'__in__', 1, all, {unreleased, <<109, 97, 105, 110>>}}, + {'__in__', 2, all, {unreleased, <<109, 97, 105, 110>>}}, + {'all?', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'all?', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'any?', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'any?', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {at, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {at, 3, all, <<118, 48, 46, 54, 46, 53>>}, + {chunk_by, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {chunk_by, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {chunk_while, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {chunk_while, 4, all, <<118, 48, 46, 54, 46, 53>>}, + {count, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {each, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {each, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {filter, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {filter, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {find, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {find, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {find_index, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {find_index, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {find_value, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {find_value, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {flat_map, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {flat_map, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {into, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {into, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {into, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {join, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {join, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {map, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {map, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {map_join, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {map_join, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'member?', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {reduce, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {reduce, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {reject, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {reject, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {reverse, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {reverse, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {split_with, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {split_with, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {to_list, 1, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.Enumerable', [ + {count, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {map, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 4, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 1, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.Enumerable.List', [ + {count, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 4, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 4, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.Enumerable.Map', [ + {count, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 3, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 1, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.Enumerable.MapSet', [ + {count, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 1, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.Enumerable.Range', [ + {count, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 1, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.ErlangError', [ + {message, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {normalize, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {normalize, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {normalize, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {normalize, 4, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.Exception', [ + {blame, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {blame, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {'exception?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {format, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {format, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {format, 4, all, <<118, 48, 46, 54, 46, 52>>}, + {format_banner, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {format_banner, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {format_banner, 4, all, <<118, 48, 46, 54, 46, 52>>}, + {format_exit, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {format_fa, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {format_fa, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {format_file_line, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {format_file_line, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {format_mfa, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {format_mfa, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {format_stacktrace, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {format_stacktrace_entry, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {format_stacktrace_entry, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {format_stacktrace_entry, 4, all, <<118, 48, 46, 54, 46, 52>>}, + {message, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {normalize, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {normalize, 3, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.Function', [ + {capture, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {capture, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {identity, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {info, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {info, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.FunctionClauseError', [ + {message, 1, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.GPIO', [ + {attach_interrupt, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {attach_interrupt, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {deep_sleep_hold_dis, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {deep_sleep_hold_en, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {deinit, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {detach_interrupt, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {digital_read, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {digital_write, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {digital_write, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {hold_dis, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {hold_en, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {open, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {read, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {read, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {remove_int, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {remove_int, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {set_direction, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {set_direction, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {set_int, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {set_int, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {set_level, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {set_level, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {set_pin_mode, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {set_pin_mode, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {set_pin_pull, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {set_pin_pull, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {'Elixir.GenServer', [ + {abcast, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {add, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {cast, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {cast, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {cast, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {child_spec, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {code_change, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {format_status, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_info, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {multi_call, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {pop, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {push, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {reply, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {subtract, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {whereis, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {whereis, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.I2C', [ + {begin_transmission, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {begin_transmission, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {end_transmission, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {open, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {read_bytes, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {read_bytes, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {read_bytes, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {write_byte, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {write_byte, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {write_bytes, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {write_bytes, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {write_bytes, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {write_bytes, 4, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {'Elixir.IO', [ + {chardata_to_string, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {inspect, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {iodata_to_binary, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {puts, 1, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {'Elixir.ImplStruct', [ + {'__protocol__', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'__protocol__', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {consolidate, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {consolidate, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'consolidated?', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {extract_impls, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {extract_impls, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {extract_protocols, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Integer', [ + {extended_gcd, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {extended_gcd, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {floor_div, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {floor_div, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {gcd, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {gcd, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {mod, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {mod, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {to_charlist, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {to_charlist, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {to_string, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {to_string, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {'Elixir.JSON', [ + {decode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decode, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {'decode!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'encode!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'encode!', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'encode_to_iodata!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'encode_to_iodata!', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {protocol_encode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {protocol_encode, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.JSON.Encoder.Atom', [ + {encode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.JSON.Encoder.BitString', [ + {encode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.JSON.Encoder.Float', [ + {encode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.JSON.Encoder.Integer', [ + {encode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.JSON.Encoder.List', [ + {encode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.JSON.Encoder.Map', [ + {encode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Kernel', [ + {abs, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'div', 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'div', 2, all, <<118, 48, 46, 54, 46, 48>>}, + {inspect, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {inspect, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {max, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {max, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {min, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {min, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'rem', 1, all, <<118, 48, 46, 54, 46, 48>>}, + {'rem', 2, all, <<118, 48, 46, 54, 46, 48>>}, + {struct, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'struct!', 2, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.KeyError', [ + {blame, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {blame, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {message, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {message, 2, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.Keyword', [ + {delete, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {delete, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {fetch, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {fetch, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'fetch!', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'fetch!', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {get, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {get, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {get_lazy, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {get_lazy, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {'has_key?', 1, all, <<118, 48, 46, 54, 46, 51>>}, + {'has_key?', 2, all, <<118, 48, 46, 54, 46, 51>>}, + {'keyword?', 1, all, <<118, 48, 46, 54, 46, 51>>}, + {'keyword?', 2, all, <<118, 48, 46, 54, 46, 51>>}, + {merge, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {merge, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {pop, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {pop, 3, all, <<118, 48, 46, 54, 46, 51>>}, + {'pop!', 1, all, <<118, 48, 46, 54, 46, 51>>}, + {'pop!', 2, all, <<118, 48, 46, 54, 46, 51>>}, + {put, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {put, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {put_new, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {put_new, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {split, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {split, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {take, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {take, 2, all, <<118, 48, 46, 54, 46, 51>>} + ]}, + {'Elixir.LEDC', [ + {channel_config, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {fade_func_install, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {fade_func_uninstall, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {fade_no_wait, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {fade_start, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {fade_start, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {fade_wait_done, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {get_duty, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {get_duty, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {get_freq, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {get_freq, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {high_speed_mode, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {low_speed_mode, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {set_duty, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {set_duty, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {set_fade_with_step, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {set_fade_with_step, 5, all, <<118, 48, 46, 54, 46, 48>>}, + {set_fade_with_time, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {set_fade_with_time, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {set_freq, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {set_freq, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {stop, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {stop, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {timer_config, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {update_duty, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {update_duty, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {'Elixir.List', [ + {'ascii_printable?', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'ascii_printable?', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {delete, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {delete, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {duplicate, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {duplicate, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {first, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {flatten, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {foldl, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {foldl, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {foldr, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {foldr, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {'improper?', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {insert_at, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {insert_at, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {keydelete, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {keydelete, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {keyfind, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {keyfind, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {'keymember?', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'keymember?', 3, all, <<118, 48, 46, 53, 46, 48>>}, + {last, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {replace_at, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {replace_at, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {'starts_with?', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'starts_with?', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {to_atom, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {to_existing_atom, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {to_float, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {to_integer, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {to_integer, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {to_string, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {to_tuple, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {update_at, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {update_at, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {wrap, 1, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {'Elixir.List.Chars', [{to_charlist, 1, all, <<118, 48, 46, 54, 46, 53>>}]}, + {'Elixir.List.Chars.Atom', [ + {to_charlist, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.List.Chars.BitString', [ + {to_charlist, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.List.Chars.Float', [ + {to_charlist, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.List.Chars.Integer', [ + {to_charlist, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.List.Chars.List', [ + {to_charlist, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.Map', [ + {delete, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {delete, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'equal?', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'equal?', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {fetch, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {fetch, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'fetch!', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'fetch!', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {from_struct, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {get, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {get, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {'has_key?', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'has_key?', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {new, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {put, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {put, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {replace, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {replace, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {'replace!', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'replace!', 3, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.MapSet', [ + {delete, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {delete, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {difference, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {difference, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'disjoint?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'disjoint?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'equal?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'equal?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {filter, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {filter, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {intersection, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {intersection, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {new, 0, all, <<118, 48, 46, 54, 46, 52>>}, + {new, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {new, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {put, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {put, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {reject, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {reject, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {size, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {split_with, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {split_with, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'subset?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'subset?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {symmetric_difference, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {symmetric_difference, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {to_list, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {union, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {union, 2, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.MatchError', [{message, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.MyApp.Periodically', [ + {abcast, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {add, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {add, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {cast, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {cast, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {child_spec, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {code_change, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {code_change, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {format_status, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_info, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {multi_call, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {reply, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {reply, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {subtract, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {subtract, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {whereis, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.MyApp.Supervisor', [ + {child_spec, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {child_spec, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {count_children, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {delete_child, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {delete_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {restart_child, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {restart_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_child, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate_child, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {which_children, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.MySupervisor', [ + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {supervise, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {supervisor, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {worker, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Process', [ + {info, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {info, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {register, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {register, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {send_after, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {send_after, 4, all, <<118, 48, 46, 54, 46, 53>>}, + {sleep, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {unregister, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {whereis, 1, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {'Elixir.Protocol', [ + {'__before_compile__', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'__concat__', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'__derive__', 3, all, <<118, 48, 46, 55, 46, 48>>}, + {'__impl__', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'__impl__', 4, all, <<118, 48, 46, 55, 46, 48>>}, + {'__impl__!', 3, all, <<118, 48, 46, 55, 46, 48>>}, + {'__protocol__', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'assert_impl!', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'assert_protocol!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {consolidate, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'consolidated?', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {extract_impls, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {extract_protocols, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {ok, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {print_size, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {reverse, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {size, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {unquote, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Protocol.UndefinedError', [ + {message, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {message, 3, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.Range', [ + {'disjoint?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'disjoint?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {new, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {new, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {new, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {size, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {size, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Reversible', [ + {'assert_impl!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'assert_impl!', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'assert_protocol!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {print_size, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {reverse, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Size', [{size, 1, all, <<118, 48, 46, 55, 46, 48>>}]}, + {'Elixir.Size.Any', [ + {reverse, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {size, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Size.BitString', [{size, 1, all, <<118, 48, 46, 55, 46, 48>>}]}, + {'Elixir.Size.Map', [{size, 1, all, <<118, 48, 46, 55, 46, 48>>}]}, + {'Elixir.Size.MapSet', [{size, 1, all, <<118, 48, 46, 55, 46, 48>>}]}, + {'Elixir.Size.Tuple', [{size, 1, all, <<118, 48, 46, 55, 46, 48>>}]}, + {'Elixir.Stack', [ + {handle_call, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {pop, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {push, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {push, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.String.Chars', [{to_string, 1, all, <<118, 48, 46, 54, 46, 53>>}]}, + {'Elixir.String.Chars.Atom', [ + {to_string, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.String.Chars.BitString', [ + {to_string, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.String.Chars.Float', [ + {to_string, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.String.Chars.Integer', [ + {to_string, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.String.Chars.List', [ + {to_string, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.Supervisor', [ + {child_spec, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {child_spec, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {child_spec, 7, all, <<118, 48, 46, 55, 46, 48>>}, + {count_children, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {delete_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {restart_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_child, 7, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {which_children, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.Supervisor.Default', [{init, 1, all, <<118, 48, 46, 55, 46, 48>>}]}, + {'Elixir.Supervisor.Spec', [ + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {supervise, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {supervise, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {supervisor, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {supervisor, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {worker, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {worker, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.System', [ + {monotonic_time, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {os_time, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {system_time, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {'Elixir.SystemLimitError', [ + {message, 1, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.TryClauseError', [{message, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.UndefinedFunctionError', [ + {blame, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {blame, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {message, 1, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.User', [ + {size, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {struct, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {struct, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'struct!', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'struct!', 2, all, <<118, 48, 46, 54, 46, 52>>} + ]}, + {'Elixir.WithClauseError', [{message, 1, all, <<118, 48, 46, 54, 46, 52>>}]}, + {'Elixir.json', [ + {decode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decode, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {'decode!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'encode!', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'encode_to_iodata!', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {protocol_encode, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.name', [ + {'__concat__', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'__concat__', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'__derive__', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'__derive__', 3, all, <<118, 48, 46, 55, 46, 48>>}, + {'__impl__', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'__impl__!', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'__impl__!', 3, all, <<118, 48, 46, 55, 46, 48>>}, + {unquote, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {'Elixir.unquote', [ + {'__before_compile__', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'__impl__', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'__impl__', 4, all, <<118, 48, 46, 55, 46, 48>>} + ]}, {ahttp_client, [ {close, 1, all, <<118, 48, 46, 54, 46, 51>>}, {connect, 4, all, <<118, 48, 46, 54, 46, 51>>}, @@ -2405,6 +3179,20 @@ {characters_to_list, 1, all, <<118, 48, 46, 54, 46, 48>>}, {characters_to_list, 2, all, <<118, 48, 46, 54, 46, 48>>} ]}, + {unknown, [ + {count, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {encode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {map, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {map, 2, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 1, all, <<118, 48, 46, 54, 46, 52>>}, + {'member?', 2, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {reduce, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {slice, 1, all, <<118, 48, 46, 54, 46, 52>>}, + {to_charlist, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {to_string, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, {websocket, [ {buffered_amount, 1, [emscripten], <<118, 48, 46, 55, 46, 48>>}, {close, 1, [emscripten], <<118, 48, 46, 55, 46, 48>>}, diff --git a/rebar.config.script b/rebar.config.script index 31d0840..7332adb 100644 --- a/rebar.config.script +++ b/rebar.config.script @@ -102,7 +102,10 @@ Version = {api_reference, true} ]} ]}, - {check, [{erl_opts, [debug_info, {i, "include"}]}, {plugins, [erlfmt]}]}, + {check, [ + {erl_opts, [debug_info, {i, "include"}]}, + {plugins, [erlfmt, rebar3_uncovered]} + ]}, {prod, [ {erl_opts, [debug_info, {i, "include"}, nowarn_sasl, {sasl, false}]} ]} @@ -137,7 +140,7 @@ Version = ]} ]}, {provider_hooks, [ - {pre, [{release, escriptize}]} + {pre, [{release, escriptize}, {tar, escriptize}]} ]}, {post_hooks, [ {release, diff --git a/release/install.in b/release/install.in index 93a1e04..9a7baa2 100644 --- a/release/install.in +++ b/release/install.in @@ -90,9 +90,14 @@ readonly tmp_dir readonly dest_dir="${prefix}/lib/atomvm_spectrometer" if [ -x "${prefix}/bin/spectrometer" ]; then installed_version="$("${prefix}"/bin/spectrometer version)" - if [ "${installed_version}" = "${version}" ] && [ "${force_install}" != "true" ]; then - echo "ERROR! It looks like ${version} is already installed! Use --force to override" - exit 1 + if [ "${installed_version}" = "${version}" ]; then + if [ "${force_install}" != "true" ]; then + echo "ERROR! It looks like ${version} is already installed! Use --force to override" + exit 1 + else + sh "${prefix}/lib/atomvm_spectrometer/uninstall.sh" || true + rm -rf "${prefix}/lib/atomvm_spectrometer" + fi fi fi diff --git a/src/atomvm_spectrometer.erl b/src/atomvm_spectrometer.erl index 519e402..eefccd6 100644 --- a/src/atomvm_spectrometer.erl +++ b/src/atomvm_spectrometer.erl @@ -61,7 +61,7 @@ main(Args) -> spectrometer_help:usage(Cmd), maybe_halt(0); {command, audit, Opts} -> - case run_analyzer_dispatch(Opts, fun run_audit/1) of + case spectrometer_analyzer:audit(Opts) of ok -> maybe_halt(0); {error, Reason} -> @@ -69,7 +69,7 @@ main(Args) -> maybe_halt(1) end; {command, ecosystem, Opts} -> - case run_ecosystem(Opts) of + case spectrometer_ecosystem:run(Opts) of ok -> maybe_halt(0); {error, Reason} -> @@ -77,7 +77,7 @@ main(Args) -> maybe_halt(1) end; {command, examine, Opts} -> - case run_analyzer_dispatch(Opts, fun run_examine/1) of + case spectrometer_analyzer:examine(Opts) of ok -> maybe_halt(0); {error, Reason} -> @@ -85,12 +85,12 @@ main(Args) -> maybe_halt(1) end; {command, supported, Opts} -> - case run_supported(Opts) of + case spectrometer_atomvm:report_supported(Opts) of ok -> maybe_halt(0); {error, _} -> maybe_halt(1) end; {command, filter, Opts} -> - case run_filter(Opts) of + case spectrometer_analyzer:filter(Opts) of ok -> maybe_halt(0); {error, Reason} -> @@ -98,12 +98,12 @@ main(Args) -> maybe_halt(1) end; {command, update, Opts} -> - case run_update(Opts) of + case spectrometer_updater:update(Opts) of ok -> maybe_halt(0); {error, _} -> maybe_halt(1) end; {command, query, Opts} -> - case run_query(Opts) of + case spectrometer_atomvm:query(Opts) of ok -> maybe_halt(0); {error, _} -> maybe_halt(1) end @@ -159,7 +159,7 @@ parse_args(["audit" | Rest]) -> parse_args(["ecosystem" | Rest]) -> case lists:any(fun(E) -> lists:member(E, ["-h", "--help"]) end, Rest) of false -> - case parse_ecosystem_args(Rest, default_eccopts()) of + case parse_ecosystem_args(Rest, default_eco_opts()) of {error, Msg} -> {error, Msg}; Opts when is_map(Opts) -> {command, ecosystem, Opts} end; @@ -307,8 +307,8 @@ parse_audit_args(["--min-count", N | Rest], Opts) -> parse_audit_args([Unknown | _], _Opts) -> {error, "Unknown option: " ++ Unknown}. --spec default_eccopts() -> opts_map(). -default_eccopts() -> +-spec default_eco_opts() -> opts_map(). +default_eco_opts() -> #{ workers => 4, github => true, @@ -359,16 +359,20 @@ parse_supported_args([], Opts) -> Opts; parse_supported_args(["--module", Mod | Rest], Opts) -> parse_supported_args(Rest, Opts#{ - module => spectrometer_utils:atom_from_string(Mod) + module => spectrometer_utils:normalize_module_name(Mod) }); parse_supported_args(["-m", Mod | Rest], Opts) -> parse_supported_args(Rest, Opts#{ - module => spectrometer_utils:atom_from_string(Mod) + module => spectrometer_utils:normalize_module_name(Mod) }); parse_supported_args(["--cache", Dir | Rest], Opts) -> parse_supported_args(Rest, Opts#{cache_dir => Dir}); parse_supported_args(["-c", Dir | Rest], Opts) -> parse_supported_args(Rest, Opts#{cache_dir => Dir}); +parse_supported_args(["--erl" | Rest], Opts) -> + parse_supported_args(Rest, Opts#{filter => erlang_only}); +parse_supported_args(["--ex" | Rest], Opts) -> + parse_supported_args(Rest, Opts#{filter => elixir_only}); parse_supported_args([Unknown | _], _) -> Reason = io_lib:format("unknown option ~s", [Unknown]), {error, Reason}. @@ -410,7 +414,8 @@ parse_filter_args([MaybeFile | Rest], Opts) -> parse_query_args([], #{query := _Q} = Opts) -> Opts; parse_query_args([], _) -> - {error, "No function specified. Usage: query Module:Function[/Arity]"}; + {error, + "No function specified. Usage: query Module:Function or Module.Function[/Arity]"}; parse_query_args(["--cache", Dir | Rest], Opts) -> parse_query_args(Rest, Opts#{cache_dir => Dir}); parse_query_args(["-c", Dir | Rest], Opts) -> @@ -451,10 +456,6 @@ parse_update_args(["--force" | Rest], Opts) -> parse_update_args([Unknown | _], _Opts) -> {error, "Unknown option: " ++ Unknown}. --doc false. -run_audit(Opts) -> - spectrometer_analyzer:audit(Opts). - -doc false. run_analyzer_dispatch(Opts, Runner) -> case spectrometer_utils:start_applications() of @@ -464,64 +465,3 @@ run_analyzer_dispatch(Opts, Runner) -> io:format("Failed to start required applications... "), {error, Reason} end. - --doc false. --spec run_ecosystem(opts_map()) -> ok | {error, term()}. -run_ecosystem(Opts) -> - spectrometer_ecosystem:run(Opts). - --doc false. -run_examine(Opts) -> - spectrometer_analyzer:examine(Opts). - --doc false. --spec run_supported(opts_map()) -> ok | {error, unsupported}. -run_supported(Opts) -> - spectrometer_atomvm:report_supported(Opts). - --doc false. --spec run_filter(opts_map()) -> ok | {error, term()}. -run_filter(Opts) -> - spectrometer_analyzer:filter(Opts). - --doc false. --spec run_query(opts_map()) -> ok | {error, term()}. -run_query(Opts) -> - spectrometer_atomvm:query(Opts). - --doc false. --spec run_update(opts_map()) -> ok | {error, term()}. -run_update(Opts) -> - case Opts of - #{cache_dir := CacheDir} -> - application:set_env(spectrometer, cache_dir, CacheDir); - #{} -> - ok - end, - OutputFile = - case Opts of - #{output := File} -> - File; - #{} -> - spectrometer_utils:user_db_file() - end, - Force = maps:get(force, Opts, false), - - case filelib:is_file(OutputFile) andalso not Force of - true -> - io:format("Output file already exists: ~s\n", [OutputFile]), - io:format("Use --force to overwrite.\n"), - {error, {file_exists, OutputFile}}; - _ -> - case spectrometer_updater:update_datafile(Opts, OutputFile) of - ok -> - ok; - {error, Reason} -> - io:format( - standard_error, "Error: unable to update data, ~p\n", [ - Reason - ] - ), - {error, Reason} - end - end. diff --git a/src/spectrometer_atomvm.erl b/src/spectrometer_atomvm.erl index 90bc861..5f323b0 100644 --- a/src/spectrometer_atomvm.erl +++ b/src/spectrometer_atomvm.erl @@ -293,13 +293,15 @@ query(Opts) -> {error, Reason} -> io:format(standard_error, "Error: ~s\n", [Reason]), io:format( - standard_error, "Usage: query Module:Function[/Arity]\n", [] + standard_error, + "Usage: query Module:Function[/Arity] or Module.Function[/Arity]\n", + [] ), {error, Reason} end. -doc """ -Parse a query string in `Module:Function[/Arity]` format. +Parse a query string in `Module:Function[/Arity]` format, or `Module.Function[/Arity]` format for Elixir modules. Returns `{ok, Module, Function, Arity}` or `{ok, Module, Function}` when no arity is specified, or `{error, Reason}` on invalid input. @@ -307,25 +309,60 @@ when no arity is specified, or `{error, Reason}` on invalid input. -spec parse_query_string(string()) -> {ok, atom(), atom(), arity()} | {ok, atom(), atom()} | {error, string()}. parse_query_string(Query) -> + % Try colon separator first (Erlang format) case string:split(Query, ":") of [ModStr, Rest] -> case string:split(Rest, "/") of [FunStr, ArityStr] -> case string:to_integer(ArityStr) of {Arity, []} when Arity >= 0 -> - {ok, spectrometer_utils:atom_from_string(ModStr), + {ok, + spectrometer_utils:normalize_module_name( + ModStr, true + ), spectrometer_utils:atom_from_string(FunStr), Arity}; _ -> {error, "Invalid arity: " ++ ArityStr} end; [FunStr] -> - {ok, spectrometer_utils:atom_from_string(ModStr), + {ok, spectrometer_utils:normalize_module_name(ModStr, true), spectrometer_utils:atom_from_string(FunStr)} end; _ -> - {error, - "Invalid format. Use Module:Function or Module:Function/Arity"} + % Try dot separator for Elixir format (Module.Function[/Arity]) + % Find the LAST dot to separate module from function + case string:rchr(Query, $.) of + 0 -> + {error, + "Invalid format. Use Module:Function, Module.Function, " + "Module:Function/Arity, or Module.Function/Arity"}; + DotPos -> + ModStr = string:slice(Query, 0, DotPos - 1), + Rest = string:slice(Query, DotPos), + case string:split(Rest, "/") of + [FunStr, ArityStr] -> + case string:to_integer(ArityStr) of + {Arity, []} when Arity >= 0 -> + Mod = spectrometer_utils:normalize_module_name( + ModStr, true + ), + {ok, Mod, + spectrometer_utils:atom_from_string( + FunStr + ), + Arity}; + _ -> + {error, "Invalid arity: " ++ ArityStr} + end; + [FunStr] -> + Mod = spectrometer_utils:normalize_module_name( + ModStr, true + ), + {ok, Mod, + spectrometer_utils:atom_from_string(FunStr)} + end + end end. -spec show_query({atom(), atom()} | {atom(), atom(), arity()}) -> ok. @@ -415,63 +452,34 @@ report_supported(Opts) -> #{} -> ok end, + Filter = maps:get(filter, Opts, undefined), case Opts of #{module := Mod} -> - print_supported(Mod); + print_supported(Mod, Filter); #{} -> - print_supported() + print_supported(Filter) end. --spec print_supported() -> ok. -print_supported() -> +-spec print_supported(atom() | undefined) -> ok. +print_supported(Filter) -> Mods = supported_modules(), - io:format("AtomVM supported OTP modules (~p total):\n\n", [length(Mods)]), + FilteredMods = filter_modules_by_type(Mods, Filter), + io:format("AtomVM supported OTP modules (~p total):\n\n", [ + length(FilteredMods) + ]), lists:foreach( - fun print_supported/1, - lists:sort(Mods) + fun(Mod) -> print_supported(Mod, Filter) end, + lists:sort(FilteredMods) ). --spec print_supported(atom()) -> ok | {error, unsupported}. -print_supported(Mod) -> +-spec print_supported(atom(), atom() | undefined) -> ok | {error, unsupported}. +print_supported(Mod, _Filter) -> case supported_db_lookup(Mod) of {ok, Funs} -> io:format("~ts (~p functions):\n", [atom_to_list(Mod), length(Funs)]), lists:foreach( fun({F, A, Platform, Since}) -> - case A of - all -> - io:format( - " ~ts/* (all arities, ~s since: ~s)\n", - [ - atom_to_list(F), - format_platforms(Platform), - format_since(Since) - ] - ); - List when is_list(List) -> - ArityStr = string:join( - [integer_to_list(X) || X <- List], "/" - ), - io:format( - " ~ts/~s (~s since: ~s)\n", - [ - atom_to_list(F), - ArityStr, - format_platforms(Platform), - format_since(Since) - ] - ); - Int when is_integer(Int) -> - io:format( - " ~ts/~p (~s since: ~s)\n", - [ - atom_to_list(F), - Int, - format_platforms(Platform), - format_since(Since) - ] - ) - end + format_function_line(F, A, Platform, Since) end, lists:sort(Funs) ), @@ -485,6 +493,18 @@ print_supported(Mod) -> {error, unsupported} end. +-spec filter_modules_by_type([atom()], atom() | undefined) -> [atom()]. +filter_modules_by_type(Mods, erlang_only) -> + lists:filter( + fun(Mod) -> not spectrometer_utils:is_elixir_module_name(Mod) end, Mods + ); +filter_modules_by_type(Mods, elixir_only) -> + lists:filter( + fun(Mod) -> spectrometer_utils:is_elixir_module_name(Mod) end, Mods + ); +filter_modules_by_type(Mods, undefined) -> + Mods. + -spec supported_db_lookup(atom()) -> {ok, [ { @@ -502,20 +522,46 @@ supported_db_lookup(Mod) -> {F, A, Platforms, Since} || {M, F, A, Platforms, Since} <- Supported, M =:= Mod ], - % ++ - % %% Also support entries without module prefix (for test compatibility) - % [ - % {F, A, Since} - % || {F, A, _Platforms, Since} <- Supported, F =:= Mod - % ] ++ - % %% Also support entries in format {F, A, Platforms, Since} (without module) - % [ - % {F, A, Since} - % || {F, A, Platforms, Since} <- Supported, - % is_list(Platforms), - % F =:= Mod - % ], case ModFuns of [] -> not_found; _ -> {ok, ModFuns} end. + +%% Format a single function line for output +-spec format_function_line( + atom(), + arity() | all | [arity()], + [atom()] | all, + binary() | {unreleased, binary()} +) -> ok. +format_function_line(Fun, all, Platform, Since) -> + io:format( + " ~ts/* (~s since: ~s)\n", + [ + atom_to_list(Fun), + format_platforms(Platform), + format_since(Since) + ] + ); +format_function_line(Fun, Arity, Platform, Since) when is_integer(Arity) -> + ArityStr = integer_to_list(Arity), + io:format( + " ~ts/~s (~s since: ~s)\n", + [ + atom_to_list(Fun), + ArityStr, + format_platforms(Platform), + format_since(Since) + ] + ); +format_function_line(Fun, ArityList, Platform, Since) when is_list(ArityList) -> + ArityStr = string:join([integer_to_list(X) || X <- ArityList], "/"), + io:format( + " ~ts/~s (~s since: ~s)\n", + [ + atom_to_list(Fun), + ArityStr, + format_platforms(Platform), + format_since(Since) + ] + ). diff --git a/src/spectrometer_help.erl b/src/spectrometer_help.erl index 748678f..4cc2964 100644 --- a/src/spectrometer_help.erl +++ b/src/spectrometer_help.erl @@ -185,6 +185,8 @@ usage_supported() -> "Options:\n" " --module Show functions for a specific OTP module\n" " -m Same as --module\n" + " --erl Show only Erlang functions (exclude Elixir)\n" + " --ex Filter output to Elixir modules (does not rename or strip module prefixes)\n" " -c Use alternate cache directory for supported functions DB\n" " --cache Same as -c\n" "\n" @@ -192,6 +194,8 @@ usage_supported() -> " spectrometer supported\n" " spectrometer supported --module gen_server\n" " spectrometer supported -m lists\n" + " spectrometer supported --ex\n" + " spectrometer supported --erl\n" " spectrometer supported -c /tmp/custom_cache\n" "\n" ). @@ -264,6 +268,11 @@ usage_query() -> " Module:Function Show all supported arities for the function\n" " Module:Function/Arity Show support for a specific arity\n" "\n" + "Elixir queries accept multiple formats (for Elixir.GPIO:digital_read/1):\n" + " Elixir.GPIO:digital_read/1 Elixir.GPIO:digital_read\n" + " GPIO:digital_read/1 GPIO:digital_read\n" + " Elixir.GPIO.digital_read/1 GPIO.digital_read/1\n" + "\n" "Options:\n" " -c Use alternate cache directory for supported functions DB\n" " --cache Same as -c\n" @@ -273,5 +282,7 @@ usage_query() -> " spectrometer query lists:map/2\n" " spectrometer query gen_server:call/3\n" " spectrometer query file:read_file\n" + " spectrometer query Elixir.GPIO:digital_read/1\n" + " spectrometer query GPIO.digital_read/1\n" " spectrometer query -c /tmp/custom_cache mock_pkg:custom_func/1\n" ). diff --git a/src/spectrometer_updater.erl b/src/spectrometer_updater.erl index 095eea3..4aee7c5 100644 --- a/src/spectrometer_updater.erl +++ b/src/spectrometer_updater.erl @@ -41,7 +41,7 @@ reported as supported on generic_unix, see TODO.md) """. -export([ - update_datafile/2 + update/1 ]). -include_lib("kernel/include/file.hrl"). @@ -53,6 +53,88 @@ reported as supported on generic_unix, see TODO.md) -define(ALL_PLATFORMS, [emscripten, esp32, generic_unix, rp2, stm32]). +-doc """ +Update the supported functions database by scanning an AtomVM repository. + +This is the main entry point for refreshing the database of supported OTP +functions. It scans an AtomVM source tree, extracts function information, +and writes a machine-readable data file. + +### Options + +The `Opts` map accepts the following keys: + +- `atomvm_dir` — Path to a local AtomVM repository. If provided, the existing + checkout is used instead of cloning a fresh copy. The directory is not deleted + after scanning. +- `branch` — Git branch to use when cloning (default: `"main"`). Ignored if + `atomvm_dir` is provided. +- `cache_dir` — Directory path for cached data. Sets the `spectrometer` + application environment. +- `force` — `true` to overwrite an existing output file. Without this flag, + the function errors if the output file already exists. +- `output` — Path for the output data file (default: user database path from + `spectrometer_utils:user_db_file/0`). +- `tag` — Git tag to check out when cloning (e.g., `"v0.7.0"`). Tags take + precedence over `branch`. +- `tests` — `false` to skip scanning test files for external function calls + (default: `true`). + +### Returns + +- `ok` on success +- `{error, Reason}` if the output file exists (without `force`), the output + cannot be written, or the repository cannot be cloned + +### Examples + +```erlang +%% Update from main branch (clones temp repo) +ok = spectrometer_updater:update(#{branch => "main"}). + +%% Update from local checkout with force +ok = spectrometer_updater:update(#{atomvm_dir => "/path/to/atomvm", force => true}). + +%% Update specific tag without scanning tests +ok = spectrometer_updater:update(#{tag => "v0.7.0", tests => false}). +``` +""". +-spec update(Opts :: map()) -> ok | {error, term()}. +update(Opts) -> + case Opts of + #{cache_dir := CacheDir} -> + application:set_env(spectrometer, cache_dir, CacheDir); + #{} -> + ok + end, + OutputFile = + case Opts of + #{output := File} -> + File; + #{} -> + spectrometer_utils:user_db_file() + end, + Force = maps:get(force, Opts, false), + + case filelib:is_file(OutputFile) andalso not Force of + true -> + io:format("Output file already exists: ~s\n", [OutputFile]), + io:format("Use --force to overwrite.\n"), + {error, {file_exists, OutputFile}}; + _ -> + case update_datafile(Opts, OutputFile) of + ok -> + ok; + {error, Reason} -> + io:format( + standard_error, "Error: unable to update data, ~p\n", [ + Reason + ] + ), + {error, Reason} + end + end. + -spec build_db_from_list([{atom(), term()}]) -> map(). build_db_from_list(Data) -> lists:foldl( @@ -180,8 +262,8 @@ update_datafile(Opts, OutputFile) -> -doc """ Scan an AtomVM repo and return supported functions with platform information. -Parses gperf files, platform NIFs, Erlang library exports, and (optionally) -test files to discover supported functions. Returns a map from +Parses gperf files, platform NIFs, Erlang and Elixir library exports, and +(optionally) test files to discover supported functions. Returns a map from `{Module, Function, Arity}` to `{Platforms, Since}` entries. #### Arguments @@ -226,14 +308,16 @@ scan_atomvm_repo(RepoDir, Opts, Since) -> Acc3 = scan_platform_nifs(PlatformsDir, Acc2, Since), io:format(" Scanning Erlang library sources...\n"), Acc4 = scan_erlang_libs(LibsDir, Acc3, Since), + % Scan Elixir libraries (exavmlib) for def exports + Acc5 = scan_elixir_libs(LibsDir, Acc4, Since), case maps:get(tests, Opts, true) of true -> io:format(" Scanning test files for external calls...\n"), - Acc5 = scan_test_files(TestsDir, Acc4, Since), - finalize(Acc5); + Acc6 = scan_test_files(TestsDir, Acc5, Since), + finalize(Acc6); false -> io:format(" Skipping test file scan (disabled)\n"), - finalize(Acc4) + finalize(Acc5) end. -doc false. @@ -274,8 +358,8 @@ write_db_file(Path, Acc) -> ), Header = [ "%% Supported AtomVM functions - machine generated, edit with extreme caution.\n", - "%% Format: [{module, [{function, arity, platforms, since}]}]\n", - "%% Platforms: 'all' or list of platform atoms [esp32, stm32, rp2, emscripten, generic_unix]\n", + "%% Format: [{Module, [{Function, Arity, Platforms, Since}]}]\n", + "%% Platforms: 'all' or list of supported platform atoms [esp32, stm32, rp2, emscripten, generic_unix]\n", "%% Since: binary version string like <<\"v0.5.0\">> or {unreleased, <<\"0.7.x\">>}\n", "\n", "[\n" @@ -345,7 +429,7 @@ branch_to_since(Branch) -> branch_sort_key(<<"main">>) -> {3, <<>>}; branch_sort_key(<<"release-", Version/binary>>) -> - {2, parse_release_version(Version)}; + {2, parse_release_branch_version(Version)}; branch_sort_key(Branch) -> case binary:split(Branch, <<".">>, [global]) of [Major, Minor, <<"x">>] -> @@ -359,8 +443,8 @@ branch_sort_key(Branch) -> {1, Branch} end. -%% Parse a release version string like "0.7" into {0, 7}. -parse_release_version(Version) -> +%% Parse a release branch version string like "0.7" into {0, 7}. +parse_release_branch_version(Version) -> Parts = binary:split(Version, <<".">>, [global]), case Parts of [Major, Minor | _] -> @@ -405,8 +489,8 @@ parse_semver_base(Base) -> try Maj = list_to_integer(Major), Min = list_to_integer(Minor), - Pat = list_to_integer(Patch), - {ok, {Maj, Min, Pat}} + Pch = list_to_integer(Patch), + {ok, {Maj, Min, Pch}} catch _:badarg -> {error, non_integer_version}; _:Reason -> {error, Reason} @@ -647,7 +731,7 @@ parse_platform_nifs(File, Platform, Acc, Since) -> end, parse_file_global( File, - "strcmp\\s*\\(\\s*\"([a-z_][a-z0-9_]*):([A-Za-z_][A-Za-z0-9_]*)/(\\d+)\"", + "strcmp\\s*\\(\\s*\"([A-Za-z_][A-Za-z0-9_]*):([A-Za-z_][A-Za-z0-9_]*)/(\\d+)\"", MergeFun, Acc ). @@ -868,8 +952,6 @@ find_first_match(Regex, [Line | Rest], Default) -> end. find_exports(Lines) -> - %% -export can span multiple lines. We need to collect all [ ... ] contents. - %% Strategy: join all lines, find all -export( ... ) blocks, parse atoms/arities. Joined = lists:join(" ", Lines), case re:run(Joined, "-export\\s*\\(([^)]+)\\)", [ @@ -889,7 +971,6 @@ find_exports(Lines) -> parse_export_list(Content) -> Trimmed = string:trim(Content), - %% Remove surrounding brackets if present Inner = case Trimmed of [$[ | Rest] -> @@ -1024,3 +1105,284 @@ find_erl_files(Dir, Acc) -> {error, _} -> Acc end. + +%% Scan Elixir library source files (.ex) for def exports +%% Exavmlib modules are supported on all platforms +scan_elixir_libs(LibsDir, Acc, Since) -> + ExavmlibDir = filename:join(LibsDir, "exavmlib"), + scan_exavmlib_dir(ExavmlibDir, Acc, all, Since). + +scan_exavmlib_dir(ExavmlibDir, Acc, Platforms, Since) -> + case filelib:is_dir(ExavmlibDir) of + true -> + ExFiles = find_ex_files(ExavmlibDir), + io:format(" Scanning exavmlib (~p .ex files)\n", [length(ExFiles)]), + lists:foldl( + fun(F, A) -> + parse_elixir_file(F, A, Platforms, Since) + end, + Acc, + ExFiles + ); + false -> + io:format(" Skipping exavmlib (not found)\n"), + Acc + end. + +%% Find all .ex files recursively in a directory +find_ex_files(Dir) -> + find_ex_files(Dir, []). + +find_ex_files(Dir, Acc) -> + case file:list_dir(Dir) of + {ok, Entries} -> + lists:foldl( + fun(Name, A) -> + Path = filename:join(Dir, Name), + case file:read_link_info(Path) of + {ok, #file_info{type = directory}} -> + case Name of + "_" ++ _ -> A; + "." ++ _ -> A; + _ -> find_ex_files(Path, A) + end; + {ok, #file_info{type = regular}} -> + case filename:extension(Name) of + ".ex" -> [Path | A]; + _ -> A + end; + _ -> + A + end + end, + Acc, + Entries + ); + {error, _} -> + Acc + end. + +%% Parse a single .ex file which may contain multiple defmodule blocks. +parse_elixir_file(File, Acc, Platforms, Since) -> + case file:read_file(File) of + {ok, Bin} -> + Content = binary_to_list(Bin), + Lines = string:split(Content, "\n", all), + % Find all exports with their module context + Exports = find_elixir_exports(Lines), + lists:foldl( + fun({ModAtom, FunName, Arity}, A) -> + Key = {ModAtom, FunName, Arity}, + maps:put(Key, {Platforms, Since}, A) + end, + Acc, + Exports + ); + {error, _} -> + Acc + end. + +%% Find all def exports with their module context. +%% Scans for defmodule/defimpl to track the active module, then associates +%% each def with its enclosing module. Returns [{ModuleNameAtom, FunName, Arity}]. +find_elixir_exports(Lines) -> + find_elixir_exports(Lines, [], undefined, []). + +find_elixir_exports([], _ModuleStack, _CurrentModule, Acc) -> + lists:reverse(Acc); +find_elixir_exports( + [Line | Rest], ModuleStack, CurrentModule, Acc +) -> + case find_elixir_module_def(Line) of + {defmodule, ModName} -> + % Entering a new defmodule block + ModAtom = spectrometer_utils:atom_from_string("Elixir." ++ ModName), + find_elixir_exports( + Rest, [ModAtom | ModuleStack], ModAtom, Acc + ); + {defimpl, ProtocolName} -> + % Entering a defimpl block (also counts as a module context) + % Without explicit for, just use the protocol name + ModAtom = spectrometer_utils:atom_from_string( + "Elixir." ++ ProtocolName + ), + find_elixir_exports( + Rest, [ModAtom | ModuleStack], ModAtom, Acc + ); + {defimpl, ProtocolName, TargetName} -> + % Entering a defimpl Protocol, for: Target block + ModAtom = spectrometer_utils:atom_from_string( + "Elixir." ++ ProtocolName ++ "." ++ TargetName + ), + find_elixir_exports( + Rest, [ModAtom | ModuleStack], ModAtom, Acc + ); + {end_block} -> + % Check if this end is at column 0 (no indentation) to detect + % module/defimpl closing vs function/clause closing + case Line of + "end" ++ _ -> + % No indentation - this closes a module/impl, pop the stack + case ModuleStack of + [CurrentModule | RestStack] -> + NewCurrent = + case RestStack of + [] -> undefined; + [NewHead | _] -> NewHead + end, + find_elixir_exports( + Rest, RestStack, NewCurrent, Acc + ); + _ -> + % No module to pop, stay unchanged + find_elixir_exports( + Rest, ModuleStack, CurrentModule, Acc + ) + end; + _ -> + % Indented end - closes function/clause, not module + find_elixir_exports(Rest, ModuleStack, CurrentModule, Acc) + end; + error -> + % Check for regular def inside a module + case find_elixir_def(Line) of + {ok, FunName, Args} -> + Arity = count_arity(Args), + FunAtom = spectrometer_utils:atom_from_string(FunName), + Export = + case CurrentModule of + undefined -> + % Fallback: use filename-based module (shouldn't happen normally) + {unknown, FunAtom, Arity}; + ModAtom -> + {ModAtom, FunAtom, Arity} + end, + find_elixir_exports( + Rest, ModuleStack, CurrentModule, [Export | Acc] + ); + skip -> + find_elixir_exports( + Rest, ModuleStack, CurrentModule, Acc + ) + end + end. + +%% Detect module boundary lines: defmodule, defimpl, end +find_elixir_module_def(Line) -> + % Check for end keyword (closing a module/impl block) + case re:run(Line, "^\\s*end\\s*$", [{capture, none}]) of + match -> + {end_block}; + nomatch -> + % Check for defmodule Name do + case + re:run(Line, "^\s*defmodule\s+([A-Za-z_][A-Za-z0-9_.]*)", [ + {capture, all_but_first, list} + ]) + of + {match, [ModName]} -> + {defmodule, ModName}; + nomatch -> + % Check for defimpl Protocol, for: Module do + case + re:run( + Line, + "^\s*defimpl\s+([A-Za-z_][A-Za-z0-9_.]*),\s*for:\s*([A-Za-z_][A-Za-z0-9_.]*)\s", + [{capture, all_but_first, list}] + ) + of + {match, [ProtocolName, TargetName]} -> + {defimpl, ProtocolName, TargetName}; + nomatch -> + % Fallback: just protocol name without explicit for + case + re:run( + Line, + "^\s*defimpl\s+([A-Za-z_][A-Za-z0-9_.]*)", + [{capture, all_but_first, list}] + ) + of + {match, [ImplName]} -> {defimpl, ImplName}; + nomatch -> error + end + end + end + end. + +%% Match "def " or "defimpl " at beginning of line, then function name +%% with parentheses. This ensures we don't match defp or comments/strings. +find_elixir_def(Line) -> + % Check for defp first - if found, this is a private function + case string:find(Line, "defp ") of + nomatch -> + find_elixir_def_impl(Line); + _ -> + skip + end. + +find_elixir_def_impl(Line) -> + % Use re:run to check for "def " at start of line + case re:run(Line, "^\\s*def\\s+", [{capture, none}]) of + match -> + extract_function_from_line(Line); + nomatch -> + skip + end. + +extract_function_from_line(Line) -> + % Pattern: def function_name(args) or def function_name do + % We've already filtered out defp, so just match "def" followed by function name + % Function names can end with ? or ! (Elixir style). + % We need to match either: + % 1. "def name(args)" with optional whitespace around args + % 2. "def name do" for zero-arity functions + Parenthesized = + re:run( + Line, + "def\\s+([a-z_][a-z0-9_]*[?!]?)\\s*\\(([^)]*)\\)", + [{capture, all_but_first, list}] + ), + ZeroArity = + re:run(Line, "def\\s+([a-z_][a-z0-9_]*[?!]?)\\s+do\\b", [ + {capture, all_but_first, list} + ]), + case Parenthesized of + {match, [FunName, Args]} -> + {ok, FunName, Args}; + _ -> + case ZeroArity of + {match, [FunName]} -> + {ok, FunName, ""}; + _ -> + skip + end + end. + +%% Count arity by counting top-level commas + 1 (minimum 0) +count_arity("") -> + 0; +count_arity(Args) -> + CleanArgs = re:replace(Args, "\\s+", "", [global, {return, list}]), + case CleanArgs of + "" -> 0; + _ -> count_top_level_commas(CleanArgs) + 1 + end. + +%% Count commas at nesting depth 0 only +%% Track depth for (), [], and {} +count_top_level_commas(Str) -> + count_top_level_commas(Str, 0, 0). + +count_top_level_commas([Char | Rest], Depth, Count) -> + case Char of + $( -> count_top_level_commas(Rest, Depth + 1, Count); + $[ -> count_top_level_commas(Rest, Depth + 1, Count); + ${ -> count_top_level_commas(Rest, Depth + 1, Count); + $) -> count_top_level_commas(Rest, Depth - 1, Count); + $] -> count_top_level_commas(Rest, Depth - 1, Count); + $} -> count_top_level_commas(Rest, Depth - 1, Count); + $, when Depth == 0 -> count_top_level_commas(Rest, Depth, Count + 1); + _ -> count_top_level_commas(Rest, Depth, Count) + end; +count_top_level_commas([], _Depth, Count) -> + Count. diff --git a/src/spectrometer_utils.erl b/src/spectrometer_utils.erl index ea5c977..c3b4a99 100644 --- a/src/spectrometer_utils.erl +++ b/src/spectrometer_utils.erl @@ -22,8 +22,11 @@ directory removal, and GitHub URL normalization for deduplication. atom_from_string/1, clone_temp_repo/2, bundled_data_path/0, + is_elixir_module_name/1, make_temp_dir/1, normalize_github_url/1, + normalize_module_name/1, + normalize_module_name/2, normalize_platform_name/1, purge_dir/1, run_git_command/2, @@ -388,9 +391,15 @@ start_applications() -> system_temp_dir() -> case os:getenv("TEMPDIR") of false -> - os:getenv("TEMP", os_temp_dir()); - Temp -> - Temp + case os:getenv("TEMP") of + false -> os_temp_dir(); + Temp when Temp =/= "" -> Temp; + _ -> os_temp_dir() + end; + Temp when Temp =/= "" -> + Temp; + _ -> + os_temp_dir() end. os_temp_dir() -> @@ -400,3 +409,48 @@ os_temp_dir() -> _ -> "/tmp" end. + +-doc """ +Detect if a module name is Elixir-style (starts with literal "Elixir." prefix). +Does not infer from capitalization. +""". +-spec is_elixir_module_name(atom() | string()) -> boolean(). +is_elixir_module_name(Atom) when is_atom(Atom) -> + is_elixir_module_name(atom_to_list(Atom)); +is_elixir_module_name(Str) when is_list(Str) -> + case Str of + "Elixir." ++ _ -> true; + _ -> false + end. + +-doc """ +Normalize module name. 'Elixir.GPIO' -> 'Elixir.GPIO', 'GPIO' -> 'Elixir.GPIO', 'lists' -> 'lists'. +For Elixir modules parsed from dot syntax, use normalize_module_name/2 with ElixirFlag=true. +""". +-spec normalize_module_name(string() | atom()) -> atom(). +normalize_module_name(Atom) when is_atom(Atom) -> + normalize_module_name(atom_to_list(Atom)); +normalize_module_name(Str) when is_list(Str) -> + normalize_module_name(Str, false). + +-spec normalize_module_name(string(), boolean()) -> atom(). +normalize_module_name(Atom, ElixirFlag) when is_atom(Atom) -> + normalize_module_name(atom_to_list(Atom), ElixirFlag); +normalize_module_name(Str, true) when is_list(Str) -> + case Str of + "Elixir." ++ _ -> + list_to_atom(Str); + [C | _] when C >= $A, C =< $Z -> + % Capitalized module name - treat as Elixir module + list_to_atom("Elixir." ++ Str); + _ -> + % Lowercase (Erlang module) or other - keep as-is + list_to_atom(Str) + end; +normalize_module_name(Str, false) when is_list(Str) -> + case Str of + "Elixir." ++ _ -> + list_to_atom(Str); + _ -> + list_to_atom(Str) + end. diff --git a/test/atomvm_spectrometer_tests.erl b/test/atomvm_spectrometer_tests.erl index d3bc744..ff9aa78 100644 --- a/test/atomvm_spectrometer_tests.erl +++ b/test/atomvm_spectrometer_tests.erl @@ -455,6 +455,22 @@ parse_supported_args_short_module_test_() -> ?assertEqual(maps, maps:get(module, Opts)) end}. +parse_supported_args_erl_test_() -> + {"parses --erl flag", fun() -> + {command, supported, Opts} = atomvm_spectrometer:parse_args( + ["supported", "--erl"] + ), + ?assertEqual(erlang_only, maps:get(filter, Opts)) + end}. + +parse_supported_args_ex_test_() -> + {"parses --ex flag", fun() -> + {command, supported, Opts} = atomvm_spectrometer:parse_args( + ["supported", "--ex"] + ), + ?assertEqual(elixir_only, maps:get(filter, Opts)) + end}. + %% ============================================================================= %% parse_filter_args/2 tests %% ============================================================================= @@ -661,16 +677,16 @@ parse_query_string_zero_arity_test_() -> ) end}. -parse_query_string_unknown_module_test_() -> - {"returns ok for non-existent module", fun() -> +parse_query_string_without_arity_test_() -> + {"returns ok for module query without arity", fun() -> ?assertEqual( - {ok, nonexistent_module_xyz, foo}, - spectrometer_atomvm:parse_query_string("nonexistent_module_xyz:foo") + {ok, module_xyz, foo}, + spectrometer_atomvm:parse_query_string("module_xyz:foo") ) end}. -parse_query_string_missing_colon_test_() -> - {"returns error for missing colon", fun() -> +parse_invalid_query_string_test_() -> + {"returns error for invalid query string", fun() -> {error, _} = spectrometer_atomvm:parse_query_string("foobar"), {error, Msg1} = spectrometer_atomvm:parse_query_string("foobar"), ?assert(string:str(Msg1, "Invalid format") > 0) @@ -682,6 +698,32 @@ parse_query_string_invalid_arity_test_() -> ?assert(string:str(Msg, "Invalid arity") > 0) end}. +parse_query_string_elixir_formats_test_() -> + {"parse Elixir query formats for Elixir.GPIO:digital_read", fun() -> + FormatsWithArity = [ + {"Elixir.GPIO.digital_read/1", + {ok, 'Elixir.GPIO', digital_read, 1}}, + {"GPIO.digital_read/1", {ok, 'Elixir.GPIO', digital_read, 1}}, + {"Elixir.GPIO:digital_read/1", + {ok, 'Elixir.GPIO', digital_read, 1}}, + {"GPIO:digital_read/1", {ok, 'Elixir.GPIO', digital_read, 1}} + ], + FormatsNoArity = [ + {"Elixir.GPIO.digital_read", {ok, 'Elixir.GPIO', digital_read}}, + {"Elixir.GPIO:digital_read", {ok, 'Elixir.GPIO', digital_read}}, + {"GPIO.digital_read", {ok, 'Elixir.GPIO', digital_read}}, + {"GPIO:digital_read", {ok, 'Elixir.GPIO', digital_read}} + ], + lists:foreach( + fun({Format, Expected}) -> + ?assertEqual( + Expected, spectrometer_atomvm:parse_query_string(Format) + ) + end, + FormatsWithArity ++ FormatsNoArity + ) + end}. + %% ============================================================================= %% Helper function tests %% ============================================================================= diff --git a/test/spectrometer_elixir_tests.erl b/test/spectrometer_elixir_tests.erl new file mode 100644 index 0000000..2c9f334 --- /dev/null +++ b/test/spectrometer_elixir_tests.erl @@ -0,0 +1,86 @@ +%% +%% Copyright (c) 2026 Winford (UncleGrumpy) +%% All rights reserved. +%% +%% This is part of atomvm_spectrometer +%% +%% SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) +%% SPDX-License-Identifier: Apache-2.0 +%% + +-module(spectrometer_elixir_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% parse_query_string/1 tests - 8 formats for Elixir.GPIO:digital_read/1 +%% ============================================================================= + +parse_query_8_formats_test() -> + FormatsWithArity = [ + {"Elixir.GPIO.digital_read/1", {ok, 'Elixir.GPIO', digital_read, 1}}, + {"GPIO.digital_read/1", {ok, 'Elixir.GPIO', digital_read, 1}}, + {"Elixir.GPIO:digital_read/1", {ok, 'Elixir.GPIO', digital_read, 1}}, + {"GPIO:digital_read/1", {ok, 'Elixir.GPIO', digital_read, 1}} + ], + FormatsNoArity = [ + {"Elixir.GPIO.digital_read", {ok, 'Elixir.GPIO', digital_read}}, + {"GPIO.digital_read", {ok, 'Elixir.GPIO', digital_read}}, + {"Elixir.GPIO:digital_read", {ok, 'Elixir.GPIO', digital_read}}, + {"GPIO:digital_read", {ok, 'Elixir.GPIO', digital_read}} + ], + lists:foreach( + fun({Format, Expected}) -> + Result = spectrometer_atomvm:parse_query_string(Format), + ?assertEqual(Expected, Result) + end, + FormatsWithArity ++ FormatsNoArity + ). + +%% ============================================================================= +%% normalize_module tests +%% ============================================================================= + +normalize_gpio_test() -> + ?assertEqual( + 'GPIO', + spectrometer_utils:normalize_module_name("GPIO") + ). + +normalize_elixir_gpio_test() -> + ?assertEqual( + 'Elixir.GPIO', + spectrometer_utils:normalize_module_name("Elixir.GPIO") + ). + +normalize_lists_test() -> + ?assertEqual( + lists, + spectrometer_utils:normalize_module_name("lists") + ). + +normalize_with_flag_test() -> + ?assertEqual( + 'Elixir.GPIO', + spectrometer_utils:normalize_module_name("GPIO", true) + ), + ?assertEqual( + 'GPIO', + spectrometer_utils:normalize_module_name("GPIO", false) + ). + +%% ============================================================================= +%% is_elixir_module_name tests +%% ============================================================================= + +is_elixir_module_name_elxir_prefix_test() -> + ?assertEqual(true, spectrometer_utils:is_elixir_module_name("Elixir.GPIO")), + ?assertEqual( + true, spectrometer_utils:is_elixir_module_name("Elixir.MyModule") + ). + +is_elixir_module_name_lowercase_test() -> + ?assertEqual(false, spectrometer_utils:is_elixir_module_name("lists")), + ?assertEqual(false, spectrometer_utils:is_elixir_module_name("maps")), + % Uppercase without Elixir prefix is no longer considered Elixir + ?assertEqual(false, spectrometer_utils:is_elixir_module_name("GPIO")), + ?assertEqual(false, spectrometer_utils:is_elixir_module_name("MyModule")). diff --git a/test/spectrometer_otp_tests.erl b/test/spectrometer_otp_tests.erl new file mode 100644 index 0000000..4b95beb --- /dev/null +++ b/test/spectrometer_otp_tests.erl @@ -0,0 +1,267 @@ +%% +%% Copyright (c) 2026 Winford (UncleGrumpy) +%% All rights reserved. +%% +%% This is part of atomvm_spectrometer +%% +%% SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) +%% SPDX-License-Identifier: Apache-2.0 +%% + +-module(spectrometer_otp_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% is_otp_module/1 tests - Atom input +%% ============================================================================= + +is_otp_module_atom_valid_test_() -> + {"identifies OTP modules from atom input", fun() -> + ?assert(spectrometer_otp:is_otp_module(lists)), + ?assert(spectrometer_otp:is_otp_module(gen_server)), + ?assert(spectrometer_otp:is_otp_module(application)), + ?assert(spectrometer_otp:is_otp_module(io)), + ?assert(spectrometer_otp:is_otp_module(filename)) + end}. + +is_otp_module_atom_invalid_test_() -> + {"rejects non-OTP modules from atom input", fun() -> + ?assertNot(spectrometer_otp:is_otp_module(nonexistent_module)), + ?assertNot(spectrometer_otp:is_otp_module(my_custom_module)) + end}. + +%% ============================================================================= +%% is_otp_module/1 tests - String input +%% ============================================================================= + +is_otp_module_string_valid_test_() -> + {"identifies OTP modules from string input", fun() -> + ?assert(spectrometer_otp:is_otp_module("lists")), + ?assert(spectrometer_otp:is_otp_module("gen_server")), + ?assert(spectrometer_otp:is_otp_module("application")), + ?assert(spectrometer_otp:is_otp_module("io")), + ?assert(spectrometer_otp:is_otp_module("filename")) + end}. + +is_otp_module_string_invalid_test_() -> + {"rejects non-OTP modules from string input", fun() -> + ?assertNot(spectrometer_otp:is_otp_module("nonexistent_module")), + ?assertNot(spectrometer_otp:is_otp_module("my_custom_module")), + ?assertNot(spectrometer_otp:is_otp_module("my_app_helper")) + end}. + +%% ============================================================================= +%% module_cache/0 tests +%% ============================================================================= + +module_cache_path_test_() -> + {"returns valid cache file path", fun() -> + CachePath = spectrometer_otp:module_cache(), + ?assert(is_list(CachePath)), + ?assert(string:find(CachePath, "_modules.bin") =/= nomatch) + end}. + +module_cache_uses_user_cache_test_() -> + {"cache path uses user cache directory", fun() -> + CachePath = spectrometer_otp:module_cache(), + UserCache = spectrometer_utils:user_cache_path(), + ?assert(string:find(CachePath, UserCache) =/= nomatch) + end}. + +module_cache_contains_otp_version_test_() -> + {"cache path contains OTP version", fun() -> + CachePath = spectrometer_otp:module_cache(), + VersionString = erlang:system_info(otp_release), + ?assert(string:find(CachePath, "otp_" ++ VersionString) =/= nomatch) + end}. + +%% ============================================================================= +%% modules_list/0 tests - Runtime generation +%% ============================================================================= + +modules_list_returns_valid_test_() -> + {"returns valid module list from runtime", fun() -> + Modules = spectrometer_otp:modules_list(), + ?assert(is_list(Modules)), + ?assert(length(Modules) > 0), + ?assert(lists:member("lists", Modules)), + ?assert(lists:member("gen_server", Modules)) + end}. + +modules_list_creates_cache_test_() -> + {"creates cache file on first run", + {setup, + fun() -> + TempDir = spectrometer_utils:make_temp_dir("otp_cache_create_"), + application:set_env(spectrometer, cache_dir, TempDir), + TempDir + end, + fun(TempDir) -> + spectrometer_utils:purge_dir(TempDir) + end, + fun(_TempDir) -> + ?_test(begin + _ = spectrometer_otp:modules_list(), + CacheFile = spectrometer_otp:module_cache(), + ?assert(filelib:is_file(CacheFile)) + end) + end}}. + +%% ============================================================================= +%% modules_list/0 tests - Cache file handling +%% ============================================================================= + +modules_list_reads_valid_cache_test_() -> + {"reads valid cached data", + {setup, + fun() -> + TempDir = spectrometer_utils:make_temp_dir("otp_cache_read_"), + application:set_env(spectrometer, cache_dir, TempDir), + % Get the actual cache file path expected by the module + CacheFile = spectrometer_otp:module_cache(), + Modules = ["lists", "io", "gen_server"], + file:write_file(CacheFile, term_to_binary(Modules)), + {TempDir, CacheFile} + end, + fun({TempDir, _}) -> + spectrometer_utils:purge_dir(TempDir) + end, + fun({_TempDir, _CacheFile}) -> + ?_test(begin + Modules = spectrometer_otp:modules_list(), + ?assertEqual(["lists", "io", "gen_server"], Modules) + end) + end}}. + +modules_list_invalid_binary_data_test_() -> + {"handles invalid binary data in cache (non-list term)", + {setup, + fun() -> + TempDir = spectrometer_utils:make_temp_dir( + "otp_cache_invalid_" + ), + application:set_env(spectrometer, cache_dir, TempDir), + CacheFile = spectrometer_otp:module_cache(), + file:write_file( + CacheFile, term_to_binary(some_atom_not_a_list) + ), + {TempDir, CacheFile} + end, + fun({TempDir, _}) -> + spectrometer_utils:purge_dir(TempDir) + end, + fun({_TempDir, _CacheFile}) -> + ?_test(begin + Modules = spectrometer_otp:modules_list(), + ?assert(is_list(Modules)), + ?assert(length(Modules) > 0) + end) + end}}. + +modules_list_non_printable_cache_test_() -> + {"handles cache with non-printable elements", + {setup, + fun() -> + TempDir = spectrometer_utils:make_temp_dir( + "otp_cache_nonprint_" + ), + application:set_env(spectrometer, cache_dir, TempDir), + CacheFile = spectrometer_otp:module_cache(), + Modules = ["lists", "\x00\x01bad", "io"], + file:write_file(CacheFile, term_to_binary(Modules)), + {TempDir, CacheFile} + end, + fun({TempDir, _}) -> + spectrometer_utils:purge_dir(TempDir) + end, + fun({_TempDir, _CacheFile}) -> + ?_test(begin + Modules = spectrometer_otp:modules_list(), + ?assert(is_list(Modules)), + ?assert( + lists:all( + fun(X) -> io_lib:printable_list(X) end, + Modules + ) + ) + end) + end}}. + +%% ============================================================================= +%% modules_list/0 tests - File read error handling +%% ============================================================================= + +modules_list_handle_read_failure_test_() -> + {"handles file read failure gracefully", + {setup, + fun() -> + TempDir = spectrometer_utils:make_temp_dir( + "otp_cache_readfail_" + ), + application:set_env(spectrometer, cache_dir, TempDir), + CacheFile = spectrometer_otp:module_cache(), + % Create a directory with the same name as the cache file to cause read error + file:make_dir(CacheFile), + {TempDir, CacheFile} + end, + fun({TempDir, CacheFile}) -> + file:del_dir(CacheFile), + spectrometer_utils:purge_dir(TempDir) + end, + fun({_TempDir, _CacheFile}) -> + ?_test(begin + Modules = spectrometer_otp:modules_list(), + ?assert(is_list(Modules)), + ?assert(length(Modules) > 0) + end) + end}}. + +%% ============================================================================= +%% Integration tests - Full cache lifecycle +%% ============================================================================= + +otp_module_cache_lifecycle_test_() -> + {"full cache create/read/reuse lifecycle", + {setup, + fun() -> + TempDir = spectrometer_utils:make_temp_dir("otp_lifecycle_"), + application:set_env(spectrometer, cache_dir, TempDir), + TempDir + end, + fun(TempDir) -> + spectrometer_utils:purge_dir(TempDir) + end, + fun(_TempDir) -> + ?_test(begin + Modules1 = spectrometer_otp:modules_list(), + CacheFile = spectrometer_otp:module_cache(), + ?assert(filelib:is_file(CacheFile)), + + Modules2 = spectrometer_otp:modules_list(), + ?assertEqual(Modules1, Modules2) + end) + end}}. + +%% ============================================================================= +%% modules_list/0 tests - Writable directory creation failure simulation +%% ============================================================================= + +modules_list_handles_missing_cache_dir_test_() -> + {"handles missing cache directory", + {setup, + fun() -> + TempDir = spectrometer_utils:make_temp_dir("otp_missing_dir_"), + application:set_env(spectrometer, cache_dir, TempDir), + spectrometer_utils:purge_dir(TempDir), + TempDir + end, + fun(TempDir) -> + filelib:ensure_path(filename:join(TempDir, "noop")) + end, + fun(_TempDir) -> + ?_test(begin + Modules = spectrometer_otp:modules_list(), + ?assert(is_list(Modules)), + ?assert(length(Modules) > 0) + end) + end}}. diff --git a/test/spectrometer_reporter_tests.erl b/test/spectrometer_reporter_tests.erl index d944a09..9d3ad00 100644 --- a/test/spectrometer_reporter_tests.erl +++ b/test/spectrometer_reporter_tests.erl @@ -7,12 +7,137 @@ %% SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) %% SPDX-License-Identifier: Apache-2.0 %% - -module(spectrometer_reporter_tests). -include_lib("eunit/include/eunit.hrl"). +-define(SAMPLE_STATS, #{ + {lists, map, 2} => 10, + {lists, filter, 2} => 5, + {io, format, 2} => 3, + {erlang, display, 1} => 1, + {string, find, 3} => 7, + {binary, match, 2} => 2 +}). + +-define(SAMPLE_REPORT, #{ + supported => [ + {{erlang, display, 1}, 1}, + {{lists, map, 2}, 10} + ], + unsupported => [ + {{io, format, 2}, 3}, + {{string, find, 3}, 7}, + {{binary, match, 2}, 2} + ], + total => 23, + total_unique => 5 +}). + +%% ============================================================================= +%% generate_report/1 tests +%% ============================================================================= + +generate_report1_delegates_test() -> + % generate_report/1 should delegate to generate_report/2 with MinCount=1 + Stats = #{{lists, map, 2} => 5}, + Report = spectrometer_reporter:generate_report(Stats), + ?assert(maps:is_key(supported, Report)), + ?assert(maps:is_key(unsupported, Report)), + ?assert(maps:is_key(total, Report)), + ?assert(maps:is_key(total_unique, Report)). + +generate_report2_filters_non_otp_test() -> + % generate_report/2 should filter out non-OTP functions + Stats = #{ + {my_app, my_func, 2} => 5, + {lists, map, 2} => 3 + }, + Report = spectrometer_reporter:generate_report(Stats, 1), + % my_app is not an OTP module, should be filtered out + Supp = maps:get(supported, Report), + Unsupp = maps:get(unsupported, Report), + ?assert( + lists:keymember({lists, map, 2}, 1, Supp) orelse + lists:keymember({lists, map, 2}, 1, Unsupp) + ). + +generate_report2_applies_min_count_test() -> + Stats = #{{lists, map, 2} => 10, {io, format, 2} => 2}, + Report = spectrometer_reporter:generate_report(Stats, 5), + Supp = maps:get(supported, Report), + Unsupp = maps:get(unsupported, Report), + All = Supp ++ Unsupp, + % Only lists:map/2 should remain (count >= 5) + ?assert(lists:keyfind({lists, map, 2}, 1, All) =/= false), + ?assert(lists:keyfind({io, format, 2}, 1, All) =:= false). + +generate_report2_empty_input_test() -> + Stats = #{}, + Report = spectrometer_reporter:generate_report(Stats, 1), + ?assertEqual([], maps:get(supported, Report)), + ?assertEqual([], maps:get(unsupported, Report)), + ?assertEqual(0, maps:get(total, Report)), + ?assertEqual(0, maps:get(total_unique, Report)). + +%% ============================================================================= +%% filter_otp_functions/1 tests +%% ============================================================================= + +filter_otp_functions_keeps_otp_test() -> + Stats = #{{lists, map, 2} => 1, {io, format, 2} => 2}, + Filtered = spectrometer_reporter:filter_otp_functions(Stats), + ?assertEqual(2, maps:size(Filtered)). + +filter_otp_functions_removes_non_otp_test() -> + Stats = #{{my_custom_mod, func, 1} => 1, {lists, map, 2} => 1}, + Filtered = spectrometer_reporter:filter_otp_functions(Stats), + ?assertEqual(1, maps:size(Filtered)), + ?assert(maps:is_key({lists, map, 2}, Filtered)). + +filter_otp_functions_empty_test() -> + ?assertEqual(#{}, spectrometer_reporter:filter_otp_functions(#{})). + +%% ============================================================================= +%% get_otp_module_set/0 tests (tested indirectly via filter_otp_functions) +%% ============================================================================= + +%% Note: get_otp_module_set/0 is not exported, so we test it indirectly +%% through filter_otp_functions which uses it internally. +%% The filter_otp_functions tests already verify OTP filtering behavior. + +%% ============================================================================= +%% sort_stats/1 tests +%% ============================================================================= + +sort_stats_descending_test() -> + Stats = [{{a, b, 1}, 1}, {{c, d, 2}, 5}, {{e, f, 3}, 3}], + Sorted = spectrometer_reporter:sort_stats(Stats), + {{_, _, _}, C1} = lists:nth(1, Sorted), + {{_, _, _}, C2} = lists:nth(2, Sorted), + {{_, _, _}, C3} = lists:nth(3, Sorted), + ?assertEqual(5, C1), + ?assertEqual(3, C2), + ?assertEqual(1, C3). + +sort_stats_empty_test() -> + ?assertEqual([], spectrometer_reporter:sort_stats([])). + +sort_stats_single_test() -> + Stats = [{{one, two, 3}, 42}], + ?assertEqual(Stats, spectrometer_reporter:sort_stats(Stats)). + +%% ============================================================================= +%% quote_csv_field/1 and needs_csv_quoting/1 tests +%% These functions are internal (-doc false.) and not exported. +%% Testing via write_csv with special characters in module/function names. +%% ============================================================================= + +%% Note: quote_csv_field and needs_csv_quoting are not exported functions +%% Their behavior is implicitly tested via write_csv with special characters. +%% To test these directly, they would need to be exported. + %% ============================================================================= -%% write_csv/2 tests +%% write_csv/2 tests (corrected data format) %% ============================================================================= write_csv_test_() -> @@ -27,12 +152,20 @@ write_csv_test_() -> {with, [ fun(Dir) -> ?_test(begin - Stats = #{ - {lists, map, 2} => {all, <<"v1.0.0">>}, - {io, format, 2} => {[esp32], <<"v2.0.0">>} + % Create a proper Report structure + Report = #{ + supported => [ + {{erlang, display, 1}, 1} + ], + unsupported => [ + {{lists, map, 2}, 10}, + {{io, format, 2}, 3} + ], + total => 14, + total_unique => 3 }, Path = filename:join(Dir, "output.csv"), - ok = spectrometer_reporter:write_csv(Path, Stats), + ok = spectrometer_reporter:write_csv(Path, Report), ?assert(filelib:is_file(Path)), {ok, Content} = file:read_file(Path), ?assert( @@ -40,6 +173,10 @@ write_csv_test_() -> ), ?assert( binary:match(Content, <<"io,format,2">>) =/= nomatch + ), + ?assert( + binary:match(Content, <<"module,function,arity">>) =/= + nomatch ) end) end @@ -58,19 +195,94 @@ write_csv_limit_test_() -> {with, [ fun(Dir) -> ?_test(begin - Stats = #{ - {lists, map, 2} => {all, <<"v1.0.0">>}, - {io, format, 2} => {[esp32], <<"v2.0.0">>}, - {erlang, display, 1} => {all, <<"v0.5.0">>} + Report = #{ + supported => [ + {{erlang, display, 1}, 1} + ], + unsupported => [ + {{lists, map, 2}, 10}, + {{io, format, 2}, 3}, + {{string, find, 3}, 7} + ], + total => 21, + total_unique => 4 }, - Path = filename:join(Dir, "output.csv"), - ok = spectrometer_reporter:write_csv(Path, Stats), + Path = filename:join(Dir, "output_limited.csv"), + ok = spectrometer_reporter:write_csv(Path, Report, 2), ?assert(filelib:is_file(Path)), {ok, Content} = file:read_file(Path), - Lines = binary:split(Content, <<"\n">>, [global]), - %% Should have header + 3 data lines + trailing empty = 5 - ?assertEqual(5, length(Lines)) + Lines = binary:split(Content, <<"\n">>, [global, trim]), + % Header + 2 data lines = 3 + ?assertEqual(3, length(Lines)) end) end ]} }. + +write_csv_limit_one_test() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("reporter_limit1_test_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Report = #{ + supported => [], + unsupported => [ + {{a, b, 1}, 1}, + {{c, d, 2}, 2}, + {{e, f, 3}, 3} + ], + total => 6, + total_unique => 3 + }, + Path = filename:join(Dir, "output_one.csv"), + ok = spectrometer_reporter:write_csv(Path, Report, 1), + {ok, Content} = file:read_file(Path), + Lines = binary:split(Content, <<"\n">>, [global, trim]), + % Header + 1 data line + ?assertEqual(2, length(Lines)) + end) + end + ]} + }. + +%% ============================================================================= +%% print_summary tests +%% ============================================================================= + +print_summary_test_() -> + { + foreach, + fun() -> + % Capture stdout would require group_leader manipulation + ok + end, + fun(_) -> ok end, + [ + ?_test(begin + Report = #{ + supported => [{{lists, map, 2}, 5}], + unsupported => [{{io, format, 2}, 3}], + total => 8, + total_unique => 2 + }, + ok = spectrometer_reporter:print_summary(Report) + end), + ?_test(begin + % All supported case + Report = #{ + supported => [{{lists, map, 2}, 5}], + unsupported => [], + total => 5, + total_unique => 1 + }, + ok = spectrometer_reporter:print_summary(Report) + end) + ] + }. diff --git a/test/spectrometer_scanner_tests.erl b/test/spectrometer_scanner_tests.erl index 46e10af..3025481 100644 --- a/test/spectrometer_scanner_tests.erl +++ b/test/spectrometer_scanner_tests.erl @@ -6,6 +6,7 @@ %% %% SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) %% SPDX-License-Identifier: Apache-2.0 +%% -module(spectrometer_scanner_tests). -include_lib("eunit/include/eunit.hrl"). @@ -113,7 +114,7 @@ parse_file_test_() -> "test() ->\n" " A = lists:map(fun(X) -> X * 2 end, [1,2,3]),\n" " B = lists:filter(fun(X) -> X > 1 end, A),\n" - " io:format(\"~p\n\", [B]).\n", + " io:format(\"~p\\n\", [B]).\n", File = create_file(Dir, "multi.erl", Source), {ok, Calls} = spectrometer_scanner:parse_file(File), ?assert(maps:is_key({lists, map, 2}, Calls)), @@ -243,3 +244,303 @@ create_file(Dir, Name, Content) -> find_expected(Dir, Basenames) -> [filename:join(Dir, B) || B <- Basenames]. + +%% ============================================================================= +%% parse_calls/1 tests - module-aware call extraction with filtering +%% ============================================================================= + +parse_calls_returns_module_name_test_() -> + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_parse_calls_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Source = + "-module(myapp). -export([start/0]). " + "start() -> lists:map(fun(X) -> X end, [1,2,3]), myapp:internal().\n", + File = filename:join(Dir, "myapp.erl"), + ok = file:write_file(File, Source), + {ok, ModName, Calls} = spectrometer_scanner:parse_calls( + File + ), + ?assertEqual(myapp, ModName), + ?assert(maps:is_key({lists, map, 2}, Calls)), + ?assertNot(maps:is_key({myapp, internal, 0}, Calls)) + end) + end + ]}}. + +parse_calls_filters_same_module_test_() -> + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_filter_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Source = + "-module(testmod). -export([a/0, b/0, c/0]). " + "a() -> b(). b() -> c(). c() -> external:func().\n", + File = filename:join(Dir, "testmod.erl"), + ok = file:write_file(File, Source), + {ok, _, Calls} = spectrometer_scanner:parse_calls(File), + ?assertEqual(1, maps:size(Calls)), + ?assert(maps:is_key({external, func, 0}, Calls)) + end) + end + ]}}. + +%% ============================================================================= +%% extract_module_name/2 tests - module name extraction edge cases +%% ============================================================================= + +extract_module_name_non_atom_test_() -> + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_modname_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Source = + "-module(?NonAtom). -export([foo/0]). foo() -> ok.\n", + File = filename:join(Dir, "bad.erl"), + ok = file:write_file(File, Source), + {ok, Forms} = epp_dodger:parse_file(File), + Result = spectrometer_scanner:extract_module_name(Forms), + ?assertEqual(undefined, Result) + end) + end + ]}}. + +extract_module_name_multiple_attrs_test_() -> + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_multi_modname_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Source = + "-module(first). -module(second). -export([foo/0]). foo() -> ok.\n", + File = filename:join(Dir, "multi.erl"), + ok = file:write_file(File, Source), + {ok, Forms} = epp_dodger:parse_file(File), + Result = spectrometer_scanner:extract_module_name(Forms), + ?assertEqual(first, Result) + end) + end + ]}}. + +%% ============================================================================= +%% Implicit fun extraction tests - fun Module:Function/Arity syntax +%% ============================================================================= + +implicit_fun_extraction_test_() -> + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_implicit_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Source = + "-module(test). -export([start/0]). " + "start() -> F1 = fun lists:map/2, F2 = fun lists:filter/2, {F1, F2}.\n", + File = filename:join(Dir, "implicit.erl"), + ok = file:write_file(File, Source), + {ok, Calls} = spectrometer_scanner:parse_file(File), + ?assert(maps:is_key({lists, map, 2}, Calls)), + ?assert(maps:is_key({lists, filter, 2}, Calls)) + end) + end + ]}}. + +%% ============================================================================= +%% BIF detection tests - erl_internal:bif/2 attribution to erlang module +%% ============================================================================= + +bif_detection_test_() -> + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_bif_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Source = + "-module(biftest). -export([test/0]). " + "test() -> L = [1,2,3], Len = length(L), tuple_size({a,b}), size({a,b}).\n", + File = filename:join(Dir, "biftest.erl"), + ok = file:write_file(File, Source), + {ok, Calls} = spectrometer_scanner:parse_file(File), + ?assert(maps:is_key({erlang, length, 1}, Calls)), + ?assert(maps:is_key({erlang, tuple_size, 1}, Calls)), + ?assert(maps:is_key({erlang, size, 1}, Calls)) + end) + end + ]}}. + +%% ============================================================================= +%% Directory skipping tests - _build, deps, .git, .rebar3 exclusion +%% ============================================================================= + +find_erl_files_skips_build_dir_test() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_skip_build_"), + ok = filelib:ensure_path(Dir), + BuildDir = filename:join(Dir, "_build"), + ok = file:make_dir(BuildDir), + create_file(BuildDir, "ignored.erl", "-module(ignored)."), + MainFile = create_file(Dir, "main.erl", "-module(main)."), + {Dir, MainFile} + end, + fun({Dir, _}) -> spectrometer_utils:purge_dir(Dir) end, + {with, [ + fun({Dir, MainFile}) -> + ?_test(begin + Result = spectrometer_scanner:find_erl_files(Dir), + ?assert(length(Result) =:= 1), + ?assert(lists:member(MainFile, Result)) + end) + end + ]} + }. + +find_erl_files_skips_deps_dir_test() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_skip_deps_"), + ok = filelib:ensure_path(Dir), + DepsDir = filename:join(Dir, "deps"), + ok = file:make_dir(DepsDir), + create_file(DepsDir, "dep.erl", "-module(dep)."), + MainFile = create_file(Dir, "main.erl", "-module(main)."), + {Dir, MainFile} + end, + fun({Dir, _}) -> spectrometer_utils:purge_dir(Dir) end, + {with, [ + fun({Dir, MainFile}) -> + ?_test(begin + Result = spectrometer_scanner:find_erl_files(Dir), + ?assert(length(Result) =:= 1), + ?assert(lists:member(MainFile, Result)) + end) + end + ]} + }. + +find_erl_files_skips_git_dir_test() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_skip_git_"), + ok = filelib:ensure_path(Dir), + GitDir = filename:join(Dir, ".git"), + ok = file:make_dir(GitDir), + create_file(GitDir, "packed.erl", "-module(packed)."), + MainFile = create_file(Dir, "main.erl", "-module(main)."), + {Dir, MainFile} + end, + fun({Dir, _}) -> spectrometer_utils:purge_dir(Dir) end, + {with, [ + fun({Dir, MainFile}) -> + ?_test(begin + Result = spectrometer_scanner:find_erl_files(Dir), + ?assert(length(Result) =:= 1), + ?assert(lists:member(MainFile, Result)) + end) + end + ]} + }. + +find_erl_files_skips_rebar3_dir_test() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_skip_rebar3_"), + ok = filelib:ensure_path(Dir), + Rebar3Dir = filename:join(Dir, ".rebar3"), + ok = file:make_dir(Rebar3Dir), + create_file(Rebar3Dir, "cache.erl", "-module(cache)."), + MainFile = create_file(Dir, "main.erl", "-module(main)."), + {Dir, MainFile} + end, + fun({Dir, _}) -> spectrometer_utils:purge_dir(Dir) end, + {with, [ + fun({Dir, MainFile}) -> + ?_test(begin + Result = spectrometer_scanner:find_erl_files(Dir), + ?assert(length(Result) =:= 1), + ?assert(lists:member(MainFile, Result)) + end) + end + ]} + }. + +%% ============================================================================= +%% Error recovery tests - malformed source handling +%% ============================================================================= + +parse_file_malformed_source_test() -> + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_malformed_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Source = + "-module(broken). -export([foo/0]). foo() -> [unclosed_list.\n", + File = filename:join(Dir, "broken.erl"), + ok = file:write_file(File, Source), + Result = spectrometer_scanner:parse_file(File), + ?assertMatch({error, _}, Result) + end) + end + ]}}. + +parse_file_binary_garbage_test() -> + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_binary_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + File = filename:join(Dir, "bad.erl"), + ok = file:write_file(File, <<255, 254, 253>>), + Result = spectrometer_scanner:parse_file(File), + ?assertMatch({error, _}, Result) + end) + end + ]}}. diff --git a/test/spectrometer_updater_tests.erl b/test/spectrometer_updater_tests.erl index 42d9df1..f74d07c 100644 --- a/test/spectrometer_updater_tests.erl +++ b/test/spectrometer_updater_tests.erl @@ -580,3 +580,616 @@ scan_via_ast_test_() -> end ]} }. + +%% ============================================================================= +%% normalize_tag/1 tests +%% ============================================================================= + +normalize_tag_strips_prerelease_test_() -> + {"strips prerelease suffixes from tags", fun() -> + ?assertEqual( + <<"v0.5.0">>, + spectrometer_updater:normalize_tag("v0.5.0-alpha.1") + ), + ?assertEqual( + <<"v0.6.0">>, + spectrometer_updater:normalize_tag("v0.6.0-rc.2") + ), + ?assertEqual( + <<"v1.0.0">>, + spectrometer_updater:normalize_tag("v1.0.0-beta.3") + ), + ?assertEqual( + <<"v0.5.0">>, + spectrometer_updater:normalize_tag("v0.5.0") + ) + end}. + +%% ============================================================================= +%% branch_sort_key/1 tests +%% ============================================================================= + +branch_sort_key_main_test_() -> + {"main branch is newest (tier 3)", fun() -> + ?assertEqual( + {3, <<>>}, spectrometer_updater:branch_sort_key(<<"main">>) + ) + end}. + +branch_sort_key_release_test_() -> + {"release branches are tier 2", fun() -> + ?assertEqual( + {2, {0, 7}}, + spectrometer_updater:branch_sort_key(<<"release-0.7">>) + ), + ?assertEqual( + {2, {1, 2}}, + spectrometer_updater:branch_sort_key(<<"release-1.2">>) + ) + end}. + +branch_sort_key_versioned_test_() -> + {"versioned branches like 0.7.x are tier 2", fun() -> + ?assertEqual( + {2, {0, 7}}, + spectrometer_updater:branch_sort_key(<<"0.7.x">>) + ), + ?assertEqual( + {2, {1, 0}}, + spectrometer_updater:branch_sort_key(<<"1.0.x">>) + ) + end}. + +branch_sort_key_unknown_test_() -> + {"unknown branches are tier 1", fun() -> + ?assertEqual( + {1, <<"feature-x">>}, + spectrometer_updater:branch_sort_key(<<"feature-x">>) + ), + ?assertEqual( + {1, <<"custom">>}, + spectrometer_updater:branch_sort_key(<<"custom">>) + ) + end}. + +%% ============================================================================= +%% parse_semver/1 tests +%% ============================================================================= + +parse_semver_valid_test_() -> + {"parses valid semantic versions", fun() -> + ?assertEqual( + {ok, {1, 2, 3}}, spectrometer_updater:parse_semver("v1.2.3") + ), + ?assertEqual( + {ok, {0, 5, 0}}, spectrometer_updater:parse_semver("v0.5.0") + ), + ?assertEqual( + {ok, {1, 2, 3}}, spectrometer_updater:parse_semver("1.2.3") + ), + ?assertEqual( + {ok, {0, 5, 0}}, spectrometer_updater:parse_semver("0.5.0") + ) + end}. + +parse_semver_prerelease_test_() -> + {"strips prerelease suffix for comparison", fun() -> + ?assertEqual( + {ok, {0, 5, 0}}, + spectrometer_updater:parse_semver("v0.5.0-alpha.1") + ), + ?assertEqual( + {ok, {0, 6, 0}}, + spectrometer_updater:parse_semver("v0.6.0-rc.2") + ) + end}. + +parse_semver_partial_test_() -> + {"handles partial versions", fun() -> + ?assertEqual( + {ok, {1, 2, 0}}, spectrometer_updater:parse_semver("v1.2") + ), + ?assertEqual({ok, {1, 0, 0}}, spectrometer_updater:parse_semver("v1")), + ?assertEqual({ok, {0, 5, 0}}, spectrometer_updater:parse_semver("0.5")) + end}. + +parse_semver_invalid_test_() -> + {"returns error for invalid versions", fun() -> + ?assertEqual( + {error, non_integer_version}, + spectrometer_updater:parse_semver("notaversion") + ), + ?assertEqual( + {error, non_integer_version}, + spectrometer_updater:parse_semver("vabc.def.ghi") + ) + end}. + +%% ============================================================================= +%% compare_semver/2 tests +%% ============================================================================= + +compare_semver_ordering_test_() -> + {"compares semantic version ordering", fun() -> + ?assertEqual( + older, + spectrometer_updater:compare_semver(<<"v0.4.0">>, <<"v0.5.0">>) + ), + ?assertEqual( + newer, + spectrometer_updater:compare_semver(<<"v0.6.0">>, <<"v0.5.0">>) + ), + ?assertEqual( + same, + spectrometer_updater:compare_semver(<<"v0.5.0">>, <<"v0.5.0">>) + ) + end}. + +compare_semver_patch_test_() -> + {"compares patch versions", fun() -> + ?assertEqual( + older, + spectrometer_updater:compare_semver(<<"v0.5.0">>, <<"v0.5.1">>) + ), + ?assertEqual( + newer, + spectrometer_updater:compare_semver(<<"v0.5.2">>, <<"v0.5.1">>) + ) + end}. + +%% ============================================================================= +%% merge_platforms_all/2 tests +%% ============================================================================= + +merge_platforms_all_both_all_test_() -> + {"all + all = all", fun() -> + ?assertEqual(all, spectrometer_updater:merge_platforms_all(all, all)) + end}. + +merge_platforms_all_all_with_list_test_() -> + {"all + list = all", fun() -> + ?assertEqual( + all, spectrometer_updater:merge_platforms_all(all, [esp32]) + ), + ?assertEqual( + all, spectrometer_updater:merge_platforms_all([esp32], all) + ) + end}. + +merge_platforms_all_two_lists_test_() -> + {"merges two platform lists", fun() -> + ?assertEqual( + [esp32, rp2], + spectrometer_updater:merge_platforms_all([esp32], [rp2]) + ), + ?assertEqual( + [esp32, rp2], + spectrometer_updater:merge_platforms_all([rp2], [esp32]) + ) + end}. + +merge_platforms_all_all_platforms_test_() -> + {"all platforms combined become 'all'", fun() -> + AllPlatforms = [emscripten, esp32, generic_unix, rp2, stm32], + ?assertEqual( + all, + spectrometer_updater:merge_platforms_all(AllPlatforms, []) + ) + end}. + +%% ============================================================================= +%% merge_platforms/2 tests +%% ============================================================================= + +merge_platforms_all_case_test_() -> + {"all + platform = all", fun() -> + ?assertEqual(all, spectrometer_updater:merge_platforms(all, esp32)), + ?assertEqual(all, spectrometer_updater:merge_platforms(all, stm32)) + end}. + +merge_platforms_new_platform_test_() -> + {"adds new platform to list", fun() -> + ?assertEqual( + [esp32, rp2], + spectrometer_updater:merge_platforms([esp32], rp2) + ) + end}. + +merge_platforms_duplicate_test_() -> + {"duplicate platform not added", fun() -> + ?assertEqual( + [esp32], + spectrometer_updater:merge_platforms([esp32], esp32) + ) + end}. + +merge_platforms_all_platforms_test_() -> + {"all five platforms combined become 'all'", fun() -> + ?assertEqual( + all, + spectrometer_updater:merge_platforms( + [emscripten, esp32, rp2, stm32], generic_unix + ) + ) + end}. + +%% ============================================================================= +%% is_digit_binary/1 tests +%% ============================================================================= + +is_digit_binary_valid_test_() -> + {"returns true for digit-only binaries", fun() -> + ?assert(spectrometer_updater:is_digit_binary(<<"123">>)), + ?assert(spectrometer_updater:is_digit_binary(<<"0">>)), + ?assert(spectrometer_updater:is_digit_binary(<<"999999">>)) + end}. + +is_digit_binary_invalid_test_() -> + {"returns false for non-digit binaries", fun() -> + ?assertNot(spectrometer_updater:is_digit_binary(<<"abc">>)), + ?assertNot(spectrometer_updater:is_digit_binary(<<"12a3">>)), + ?assertNot(spectrometer_updater:is_digit_binary(<<>>)) + end}. + +%% ============================================================================= +%% build_db_from_list/1 tests +%% ============================================================================= + +build_db_from_list_test_() -> + {"builds database map from list of entries", fun() -> + Data = [ + {my_module, [ + {func1, 1, all, {unreleased, <<"main">>}}, + {func2, 2, [esp32], {unreleased, <<"main">>}} + ]} + ], + DB = spectrometer_updater:build_db_from_list(Data), + ?assertEqual( + {all, {unreleased, <<"main">>}}, + maps:get({my_module, func1, 1}, DB) + ), + ?assertEqual( + {[esp32], {unreleased, <<"main">>}}, + maps:get({my_module, func2, 2}, DB) + ) + end}. + +%% ============================================================================= +%% find_first_match/2,3 tests +%% ============================================================================= + +find_first_match_found_test_() -> + {"finds first matching line", fun() -> + Lines = [ + "% Some comment", + "-module(test_mod).", + "-export([test/0])." + ], + ?assertEqual( + test_mod, + spectrometer_updater:find_first_match( + "-module\\s*\\(\\s*([a-z_][a-z0-9_]*)\\s*\\)\\s*\\.", Lines + ) + ) + end}. + +find_first_match_not_found_test_() -> + {"returns undefined when no match", fun() -> + Lines = ["something else", "-export([test/0])."], + ?assertEqual( + undefined, + spectrometer_updater:find_first_match( + "-module\\s*\\(\\s*([a-z_][a-z0-9_]*)\\s*\\)\\s*\\.", Lines + ) + ) + end}. + +%% ============================================================================= +%% find_exports/1 tests +%% ============================================================================= + +find_exports_single_test_() -> + {"finds exports from single-line -export", fun() -> + Lines = ["-export([func/1, other/2])."], + ?assertEqual( + [{func, 1}, {other, 2}], + lists:sort(spectrometer_updater:find_exports(Lines)) + ) + end}. + +find_exports_multiline_test_() -> + {"finds exports from multi-line -export", fun() -> + Lines = [ + "-export([", + " func1/1,", + " func2/2", + "])." + ], + ?assertEqual( + [{func1, 1}, {func2, 2}], + lists:sort(spectrometer_updater:find_exports(Lines)) + ) + end}. + +find_exports_none_test_() -> + {"returns empty for no exports", fun() -> + ?assertEqual([], spectrometer_updater:find_exports(["-module(test)."])), + ?assertEqual([], spectrometer_updater:find_exports([])) + end}. + +%% ============================================================================= +%% parse_export_list/1 tests +%% ============================================================================= + +parse_export_list_basic_test_() -> + {"parses basic export list", fun() -> + ?assertEqual( + [{bar, 2}, {foo, 1}], + lists:sort(spectrometer_updater:parse_export_list("[foo/1,bar/2]")) + ) + end}. + +parse_export_list_with_spaces_test_() -> + {"handles spaces around commas", fun() -> + ?assertEqual( + [{bar, 2}, {foo, 1}], + lists:sort(spectrometer_updater:parse_export_list("foo/1 , bar/2")) + ) + end}. + +%% ============================================================================= +%% find_erl_files/2 tests +%% ============================================================================= + +find_erl_files_test_() -> + {"finds .erl files recursively", + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("erl_files_test_"), + ok = filelib:ensure_path(filename:join(Dir, "subdir")), + ok = file:write_file(filename:join(Dir, "a.erl"), ""), + ok = file:write_file( + filename:join(filename:join(Dir, "subdir"), "b.erl"), "" + ), + ok = file:write_file(filename:join(Dir, "skip.txt"), ""), + Dir + end, + fun(Dir) -> spectrometer_utils:purge_dir(Dir) end, fun( + Dir + ) -> + ?_test(begin + Files = spectrometer_updater:find_erl_files(Dir), + ?assertEqual(2, length(Files)), + Names = lists:sort([filename:basename(F) || F <- Files]), + ?assertEqual(["a.erl", "b.erl"], Names) + end) + end}}. + +%% ============================================================================= +%% count_arity/1 tests +%% ============================================================================= + +count_arity_test_() -> + {"counts function arity from argument string", fun() -> + ?assertEqual(0, spectrometer_updater:count_arity("")), + ?assertEqual(1, spectrometer_updater:count_arity("x")), + ?assertEqual(2, spectrometer_updater:count_arity("x, y")), + ?assertEqual(3, spectrometer_updater:count_arity("x, y, z")), + ?assertEqual(2, spectrometer_updater:count_arity(" x , y ")) + end}. + +%% ============================================================================= +%% find_elixir_module_def/1 tests +%% ============================================================================= + +find_elixir_module_def_defmodule_test_() -> + {"detects defmodule declarations", fun() -> + ?assertEqual( + {defmodule, "MyModule"}, + spectrometer_updater:find_elixir_module_def("defmodule MyModule do") + ), + ?assertEqual( + {defmodule, "GPIO.Driver"}, + spectrometer_updater:find_elixir_module_def( + " defmodule GPIO.Driver do" + ) + ) + end}. + +find_elixir_module_def_defimpl_test_() -> + {"detects defimpl declarations", fun() -> + ?assertEqual( + {defimpl, "SomeProtocol", "SomeModule"}, + spectrometer_updater:find_elixir_module_def( + "defimpl SomeProtocol, for: SomeModule do" + ) + ), + ?assertEqual( + {defimpl, "SomeProtocol"}, + spectrometer_updater:find_elixir_module_def( + "defimpl SomeProtocol do" + ) + ) + end}. + +find_elixir_module_def_defimpl_for_test_() -> + {"detects defimpl for keyword", fun() -> + ?assertEqual( + {defimpl, "Enumerable", "List"}, + spectrometer_updater:find_elixir_module_def( + "defimpl Enumerable, for: List do" + ) + ) + end}. + +find_elixir_module_def_end_test_() -> + {"detects end keyword", fun() -> + ?assertEqual( + {end_block}, spectrometer_updater:find_elixir_module_def("end") + ), + ?assertEqual( + {end_block}, spectrometer_updater:find_elixir_module_def(" end ") + ) + end}. + +find_elixir_module_def_none_test_() -> + {"returns error for non-matching lines", fun() -> + ?assertEqual( + error, spectrometer_updater:find_elixir_module_def("some code") + ), + ?assertEqual( + error, spectrometer_updater:find_elixir_module_def("def foo() do") + ) + end}. + +%% ============================================================================= +%% find_elixir_def/1 tests +%% ============================================================================= + +find_elixir_def_public_test_() -> + {"detects public def functions", fun() -> + ?assertEqual( + {ok, "my_func", "x, y"}, + spectrometer_updater:find_elixir_def(" def my_func(x, y) do") + ), + ?assertEqual( + {ok, "valid?", "x"}, + spectrometer_updater:find_elixir_def("def valid?(x) do") + ), + ?assertEqual( + {ok, "risky!", "x"}, + spectrometer_updater:find_elixir_def("def risky!(x) do") + ) + end}. + +find_elixir_def_private_test_() -> + {"skips defp private functions", fun() -> + ?assertEqual( + skip, + spectrometer_updater:find_elixir_def("defp private_func(x) do") + ), + ?assertEqual( + skip, spectrometer_updater:find_elixir_def(" defp hidden() do") + ) + end}. + +find_elixir_def_none_test_() -> + {"returns skip for non-def lines", fun() -> + ?assertEqual( + skip, spectrometer_updater:find_elixir_def("some other code") + ), + ?assertEqual( + skip, spectrometer_updater:find_elixir_def("defmodule Test do") + ) + end}. + +%% ============================================================================= +%% extract_function_from_line/1 tests +%% ============================================================================= + +extract_function_from_line_basic_test_() -> + {"extracts function name and args", fun() -> + ?assertEqual( + {ok, "func", "x, y"}, + spectrometer_updater:extract_function_from_line( + " def func(x, y) do" + ) + ), + ?assertEqual( + {ok, "check?", "x"}, + spectrometer_updater:extract_function_from_line("def check?(x) do") + ) + end}. + +extract_function_from_line_no_parens_test_() -> + {"handles function with empty args", fun() -> + ?assertEqual( + {ok, "noargs", ""}, + spectrometer_updater:extract_function_from_line("def noargs() do") + ) + end}. + +extract_function_from_line_error_test_() -> + {"returns error for malformed lines", fun() -> + ?assertEqual( + skip, spectrometer_updater:extract_function_from_line("def") + ) + end}. + +%% ============================================================================= +%% find_elixir_exports/1 tests +%% ============================================================================= + +find_elixir_exports_test_() -> + {"extracts exports from elixir source lines", fun() -> + Lines = [ + "defmodule TestModule do", + " def public(x) do", + " x", + " end", + " defp private(x) do", + " x", + " end", + " def with_qmark?(x) do", + " x", + " end", + " def bang!() do", + " :ok", + " end", + "end" + ], + Exports = spectrometer_updater:find_elixir_exports(Lines), + Expected = [ + {'Elixir.TestModule', 'bang!', 0}, + {'Elixir.TestModule', public, 1}, + {'Elixir.TestModule', 'with_qmark?', 1} + ], + ?assertEqual(Expected, lists:sort(Exports)) + end}. + +scan_exavmlib_dir_test_() -> + {"scans exavmlib directory for .ex files", + {setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("exavmlib_test_"), + ExDir = filename:join(Dir, "exavmlib"), + ok = filelib:ensure_path(filename:join(ExDir, "sub")), + ExContent = << + "defmodule MyModule do\n", + " def public_func(x) do\n", + " x\n", + " end\n", + " defp private_func(x) do\n", + " x\n", + " end\n", + "end\n", + "\n", + "defmodule OtherMod do\n", + " def another() do\n", + " :ok\n", + " end\n", + "end\n" + >>, + ok = file:write_file( + filename:join(ExDir, "my_module.ex"), ExContent + ), + Dir + end, + fun(Dir) -> fun() -> spectrometer_utils:purge_dir(Dir) end end, fun( + Dir + ) -> + ?_test(begin + ExDir = filename:join(Dir, "exavmlib"), + Acc = spectrometer_updater:scan_exavmlib_dir( + ExDir, #{}, all, {unreleased, <<"main">>} + ), + ?assert(is_map(Acc)), + ?assert( + maps:is_key({'Elixir.MyModule', public_func, 1}, Acc) + ), + ?assert(maps:is_key({'Elixir.OtherMod', another, 0}, Acc)), + ?assertNot( + maps:is_key({'Elixir.MyModule', private_func, 1}, Acc) + ) + end) + end}}. diff --git a/test/spectrometer_utils_tests.erl b/test/spectrometer_utils_tests.erl index 7c90507..5d626ce 100644 --- a/test/spectrometer_utils_tests.erl +++ b/test/spectrometer_utils_tests.erl @@ -251,3 +251,284 @@ http_get_test_() -> _ -> [{"skipped (network tests disabled)", fun() -> ok end}] end. + +%% ============================================================================= +%% find_executable/1 tests +%% ============================================================================= + +find_executable_exists_test_() -> + {"finds git executable when present", + case os:find_executable("git") of + false -> + {skip, "git not in PATH"}; + Path -> + ?_assertEqual( + {ok, Path}, spectrometer_utils:find_executable("git") + ) + end}. + +find_executable_not_found_test_() -> + {"returns error for non-existent executable", + ?_assertEqual( + {error, not_found}, + spectrometer_utils:find_executable("nonexistent_command_xyz123") + )}. + +%% ============================================================================= +%% run_git_command/2 tests +%% ============================================================================= + +run_git_command_happy_path_test_() -> + {"returns output for git --version", + case os:find_executable("git") of + false -> + {skip, "git not in PATH"}; + _ -> + ?_assertMatch( + {ok, _}, + spectrometer_utils:run_git_command(["--version"], []) + ) + end}. + +run_git_command_empty_env_test_() -> + {"handles empty environment vars list", + case os:find_executable("git") of + false -> + {skip, "git not in PATH"}; + _ -> + ?_assertMatch( + {ok, _}, + spectrometer_utils:run_git_command(["--version"], []) + ) + end}. + +%% ============================================================================= +%% system_temp_dir/0 tests +%% ============================================================================= + +system_temp_dir_default_test_() -> + {"returns default temp dir when TEMPDIR not set", fun() -> + OldTempdir = os:getenv("TEMPDIR"), + os:putenv("TEMPDIR", ""), + try + % When TEMPDIR is empty string, os:getenv returns "", not false + % The function should fall through to TEMP or default + Result = spectrometer_utils:system_temp_dir(), + ?assert(is_list(Result)) + after + case OldTempdir of + false -> os:unsetenv("TEMPDIR"); + _ -> os:putenv("TEMPDIR", OldTempdir) + end + end + end}. + +system_temp_dir_env_test_() -> + {"uses TEMPDIR environment variable when set", fun() -> + OldTempdir = os:getenv("TEMPDIR"), + os:putenv("TEMPDIR", "/custom/tmp"), + try + ?assertEqual("/custom/tmp", spectrometer_utils:system_temp_dir()) + after + case OldTempdir of + false -> os:unsetenv("TEMPDIR"); + _ -> os:putenv("TEMPDIR", OldTempdir) + end + end + end}. + +%% ============================================================================= +%% version/0 tests +%% ============================================================================= + +version_success_test_() -> + {"returns version string on success", + case spectrometer_utils:version() of + Vsn when is_list(Vsn) -> + ?_assert(is_list(Vsn)); + {error, version_not_found} -> + {skip, "version not configured in app"} + end}. + +%% ============================================================================= +%% start_applications/0 tests +%% ============================================================================= + +start_applications_success_test_() -> + {"returns ok on successful start", + ?_assertEqual(ok, spectrometer_utils:start_applications())}. + +%% ============================================================================= +%% normalize_module_name/1 and /2 tests +%% ============================================================================= + +normalize_module_name_string_test_() -> + {"normalizes string module name", + ?_assertEqual( + 'Elixir.GPIO', + spectrometer_utils:normalize_module_name("Elixir.GPIO") + )}. + +normalize_module_name_atom_test_() -> + {"normalizes atom module name", + ?_assertEqual( + 'Elixir.GPIO', + spectrometer_utils:normalize_module_name('Elixir.GPIO') + )}. + +normalize_module_name_non_elixir_atom_test_() -> + {"normalizes atom without Elixir prefix", + ?_assertEqual(lists, spectrometer_utils:normalize_module_name(lists))}. + +normalize_module_name_2_atom_test_() -> + {"normalize_module_name/2 with atom and ElixirFlag=true", + ?_assertEqual( + 'Elixir.GPIO', + spectrometer_utils:normalize_module_name('GPIO', true) + )}. + +normalize_module_name_2_string_test_() -> + {"normalize_module_name/2 with string and ElixirFlag=true", + ?_assertEqual( + 'Elixir.GPIO', + spectrometer_utils:normalize_module_name("GPIO", true) + )}. + +normalize_module_name_2_false_flag_test_() -> + {"normalize_module_name/2 with ElixirFlag=false preserves Elixir prefix", + ?_assertEqual( + 'Elixir.GPIO', + spectrometer_utils:normalize_module_name("Elixir.GPIO", false) + )}. + +%% ============================================================================= +%% find_first_file/1 tests +%% ============================================================================= + +find_first_file_found_test_() -> + {"finds first existing file", fun() -> + TempDir = os:getenv("TMPDIR"), + UniqueSuffix = erlang:unique_integer([positive]), + MarkerFile = + case TempDir of + false -> + filename:join( + "/tmp", + "spectrometer_utils_test_marker_" ++ + integer_to_list(UniqueSuffix) + ); + _ -> + filename:join( + TempDir, + "spectrometer_utils_test_marker_" ++ + integer_to_list(UniqueSuffix) + ) + end, + file:write_file(MarkerFile, ""), + try + Result = spectrometer_utils:find_first_file([ + "/nonexistent/file1", + MarkerFile, + "/nonexistent/file2" + ]), + ?assertEqual(MarkerFile, Result) + after + file:delete(MarkerFile) + end + end}. + +find_first_file_default_test_() -> + {"returns default when no files exist", + ?_assertEqual( + "priv/supported_functions.data", + spectrometer_utils:find_first_file([ + "/nonexistent/file1", "/nonexistent/file2" + ]) + )}. + +%% ============================================================================= +%% drain_port_messages/1 tests +%% ============================================================================= + +drain_port_messages_empty_test_() -> + {"returns ok when no messages pending", + ?_assertEqual(ok, spectrometer_utils:drain_port_messages(make_ref()))}. + +drain_port_messages_with_messages_test_() -> + {"drains pending port messages", fun() -> + Port = make_ref(), + self() ! {Port, {data, {eol, "test1"}}}, + self() ! {Port, {data, {eol, "test2"}}}, + self() ! {Port, {exit_status, 0}}, + ?assertEqual(ok, spectrometer_utils:drain_port_messages(Port)), + % Verify no more messages + {messages, []} = process_info(self(), messages) + end}. + +%% ============================================================================= +%% bundled_data_path/0 tests +%% ============================================================================= + +bundled_data_path_test_() -> + {"returns path to bundled data file", + ?_assert(is_list(spectrometer_utils:bundled_data_path()))}. + +%% ============================================================================= +%% clone_temp_repo/2 tests +%% ============================================================================= + +clone_temp_repo_branch_test_() -> + {"clones repo with branch only", + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + case os:find_executable("git") of + false -> + {skip, "git not in PATH"}; + _ -> + {"runs git clone", fun() -> + Result = spectrometer_utils:clone_temp_repo( + "main", undefined + ), + case Result of + Dir when is_list(Dir) -> + try + ?assert(filelib:is_dir(Dir)) + after + spectrometer_utils:purge_dir(Dir) + end; + {error, _} -> + ok + end + end} + end; + _ -> + {skip, "network tests disabled"} + end}. + +clone_temp_repo_tag_test_() -> + {"clones repo with branch and tag", + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + case os:find_executable("git") of + false -> + {skip, "git not in PATH"}; + _ -> + {"runs git clone with tag", fun() -> + Result = spectrometer_utils:clone_temp_repo( + "main", "v0.6.0" + ), + case Result of + Dir when is_list(Dir) -> + try + ?assert(filelib:is_dir(Dir)) + after + spectrometer_utils:purge_dir(Dir) + end; + {error, _} -> + ok + end + end} + end; + _ -> + {skip, "network tests disabled"} + end}.