From 90113efe681ed2c7717283b7a79ca38e9d4407c0 Mon Sep 17 00:00:00 2001 From: Winford Date: Thu, 16 Apr 2026 17:14:04 +0000 Subject: [PATCH 1/2] Introduce spectrometer ecosystem anaysis tool MVP, likely still bugs, and refactoring is needed. Signed-off-by: Winford --- .gitignore | 28 +- LICENSES/Apache-2.0.txt | 171 ++ README.md | 149 ++ TODO.md | 136 ++ generate_fun_data.sh | 143 ++ include/ecosystem.hrl | 14 + priv/supported_functions.data | 2412 ++++++++++++++++++++++++++ rebar.config | 87 + rebar.lock | 1 + rebar.lock.license | 7 + src/atomvm_spectrometer.erl | 527 ++++++ src/spectrometer.app.src | 40 + src/spectrometer.erl | 28 + src/spectrometer_analyzer.erl | 631 +++++++ src/spectrometer_atomvm.erl | 521 ++++++ src/spectrometer_ecosystem.erl | 481 +++++ src/spectrometer_help.erl | 277 +++ src/spectrometer_http.erl | 513 ++++++ src/spectrometer_otp.erl | 116 ++ src/spectrometer_reporter.erl | 366 ++++ src/spectrometer_scanner.erl | 369 ++++ src/spectrometer_updater.erl | 1026 +++++++++++ src/spectrometer_utils.erl | 402 +++++ test/atomvm_spectrometer_tests.erl | 866 +++++++++ test/cli_main_tests.erl | 893 ++++++++++ test/spectrometer_analyzer_tests.erl | 208 +++ test/spectrometer_atomvm_tests.erl | 388 +++++ test/spectrometer_http_tests.erl | 428 +++++ test/spectrometer_reporter_tests.erl | 76 + test/spectrometer_scanner_tests.erl | 245 +++ test/spectrometer_updater_tests.erl | 582 +++++++ test/spectrometer_utils_tests.erl | 253 +++ 32 files changed, 12373 insertions(+), 11 deletions(-) create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 README.md create mode 100644 TODO.md create mode 100755 generate_fun_data.sh create mode 100644 include/ecosystem.hrl create mode 100644 priv/supported_functions.data create mode 100644 rebar.config create mode 100644 rebar.lock create mode 100644 rebar.lock.license create mode 100644 src/atomvm_spectrometer.erl create mode 100644 src/spectrometer.app.src create mode 100644 src/spectrometer.erl create mode 100644 src/spectrometer_analyzer.erl create mode 100644 src/spectrometer_atomvm.erl create mode 100644 src/spectrometer_ecosystem.erl create mode 100644 src/spectrometer_help.erl create mode 100644 src/spectrometer_http.erl create mode 100644 src/spectrometer_otp.erl create mode 100644 src/spectrometer_reporter.erl create mode 100644 src/spectrometer_scanner.erl create mode 100644 src/spectrometer_updater.erl create mode 100644 src/spectrometer_utils.erl create mode 100644 test/atomvm_spectrometer_tests.erl create mode 100644 test/cli_main_tests.erl create mode 100644 test/spectrometer_analyzer_tests.erl create mode 100644 test/spectrometer_atomvm_tests.erl create mode 100644 test/spectrometer_http_tests.erl create mode 100644 test/spectrometer_reporter_tests.erl create mode 100644 test/spectrometer_scanner_tests.erl create mode 100644 test/spectrometer_updater_tests.erl create mode 100644 test/spectrometer_utils_tests.erl diff --git a/.gitignore b/.gitignore index 751a61d..5ebe56f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,23 @@ +# SPDX-FileCopyrightText: 2026 Winford (Uncle Grumpy) +# SPDX-License-Identifier: CC0-1.0 + +.rebar3 +_build +doc +_checkouts +_vendor .eunit *.o *.beam *.plt erl_crash.dump -.concrete/DEV_MODE - -# rebar 2.x +*.swp +*.swo +.erlang.cookie +ebin +log* .rebar -rel/example_project -ebin/*.beam -deps - -# rebar 3 -.rebar3 -_build/ -_checkouts/ +rebar3.crashdump +.DS_Store +.vscode/ +priv/supported_functions.data.*.bak \ No newline at end of file diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..33409de --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,171 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, where such +license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +1. You must give any other recipients of the Work or Derivative Works a copy of + this License; and + +2. You must cause any modified files to carry prominent notices stating that + You changed the files; and + +3. You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices from + the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and + +4. If the Work includes a "NOTICE" text file as part of its distribution, then + any Derivative Works that You distribute must include a readable copy of the + attribution notices contained within such NOTICE file, excluding those + notices that do not pertain to any part of the Derivative Works, in at least + one of the following places: within a NOTICE text file distributed as part + of the Derivative Works; within the Source form or documentation, if provided + along with the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices normally appear. + The contents of the NOTICE file are for informational purposes only and do not + modify the License. You may add Your own attribution notices within + Derivative Works that You distribute, alongside or as an addendum to the + NOTICE text from the Work, provided that such additional attribution notices + cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, NON- +INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or redistributing +the Work and assume any risks associated with Your exercise of permissions +under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d4a4e7 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ + + +# spectrometer + +Analyze the atomic signatures of the BEAM ecosystem against the AtomVM spectrum +to instantly determine compatibility and porting effort across Hex.pm and +GitHub packages. + +## Dependencies + +This tool uses `git` for gathering ecosystem data and scanning repositories for +compatibility with AtomVM. + +**Runtime requirements:** + +- Erlang/OTP 27+ (uses `json`, `uri_string` modules) +- `git` CLI (for cloning GitHub repositories during ecosystem and compatibility +scans) + +## Build + + rebar3 compile + +This compiles the application modules. To produce a standalone executable: + + rebar3 escriptize + +This bundles all modules into a single escript at +`_build/default/bin/spectrometer`, which can be run directly or +installed system-wide. + +## Commands + +| Command | Description | +|------------|-----------------------------------------------------------------------------------| +| `audit` | Audit a single target (or list in a file) for AtomVM support _*_ | +| `ecosystem`| Scan top GitHub repos and/or Hex packages (gathers raw stats) | +| `examine` | Examine the modules and functions used in a single target (or list in a file) _*_ | +| `supported`| List all AtomVM-supported OTP functions | +| `filter` | Filter ecosystem scan results (use `--avm` for unsupported only) | +| `update` | Regenerate supported functions database from AtomVM sources | +| `query` | Query whether a specific OTP function is supported | + +_*_ _GitHub repo, Hex package, or directory_ + +### Help + +Get the help overview using any of the following: + + spectrometer help + spectrometer --help + spectrometer -h + +Get detailed help on any command: + + spectrometer help audit + spectrometer help ecosystem + spectrometer help examine + spectrometer help supported + spectrometer help filter + spectrometer help update + spectrometer help query + +Or use `-h` or `--help` option: + + spectrometer audit -h + spectrometer query --help + +## Examples + +### Audit a single target + + spectrometer audit --github https://github.com/ninenines/cowboy + spectrometer audit --hex jsx + spectrometer audit --hex cowboy --version 3.1.0 + spectrometer audit --dir /path/to/project + spectrometer audit --multi targets.txt -o report.csv + +### Scan the ecosystem + + spectrometer ecosystem + spectrometer ecosystem --github-only --limit 100 + spectrometer ecosystem --hex-only --workers 8 --resume + +### Filter ecosystem output + + spectrometer filter + spectrometer filter --avm + spectrometer filter --avm --min-repos 50 + spectrometer filter --min-repos 75 + +### Query function support + + spectrometer query lists:map + spectrometer query lists:map/2 + +### List supported functions + + spectrometer supported + spectrometer supported --module gen_server + spectrometer supported -m lists + +### Regenerate supported functions database + + spectrometer update + spectrometer update --tag v0.7.0-alpha.1 + spectrometer update --atomvm-dir ~/work/AtomVM + spectrometer update --branch release-0.7 --force + spectrometer update --branch main --force + +## Supported Functions Data + +The AtomVM-supported functions data is stored in +`priv/supported_functions.data`, a human-readable Erlang term list containing +`[{Module, [{Function, Arity, Platforms, Since}]}]` entries. This file can be +regenerated by running the included `generate_fun_data.sh` (a backup of the +current file will be saved). + +### User Override + +You can override the bundled database by placing your own +`supported_functions.data` in your cache directory: + +| Platform | Path | +|----------|----------------------------------------------------------| +| Linux | `~/.cache/spectrometer/supported_functions.data` | +| macOS | `~/Library/Caches/spectrometer/supported_functions.data` | +| Windows | `%APPDATA%/spectrometer/supported_functions.data` | + +Use the `update` command to generate, or update using the `--force` option, the +user override database and add new functions supported by AtomVM: + + spectrometer update --atomvm-dir ~/work/AtomVM --force + spectrometer update --branch main + spectrometer update --tag v0.7.0-alpha.1 --force + +Note: --atomvm-dir ignores --branch/--tag + +This can be used to keep the application in sync with changes to AtomVM between +update releases of the spectrometer tool. This project is still under early +development, and the data structure of this file may change between releases +until APIs are finalized. + +## Roadmap - planned enhancements + +See: [todo](TODO.md) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..eb86f4b --- /dev/null +++ b/TODO.md @@ -0,0 +1,136 @@ + + +# TODOs for atomvm spectrometer + +## Must have + +### Elixir support + +All of the current functions only work for the Erlang ecosystem. Support needs +to be added for all commands. + +* `ecosystem` command should have an --elixir option for creating an Elixir +ecosystem data-set. +* `update` and `audit` commands should automatically include `exavmlib` in the +supported functions data and lookups. +* `supported` command should list all supported modules/functions by default +and accept optional `--erl` and `--ex` flags to filter the modules/functions +output to only the selected language. + +### Change data structure for stored version info + +The current version data is stored as a binary string for tags and branches, +this is brittle when comparing versions in the `update` command, and will cause +noticeable errors if any AtomVM release versions use double digits for major, +minor or patch levels. The storage format should be migrated to tuples. + +#### Tagged releases + +```erlang +{ + Major :: non_neg_integer(), + Minor :: non_neg_integer(), + Patch :: non_neg_integer() +} +``` + +#### Branches + +##### main + +```erlang +{ + unreleased, + main +} +``` + +##### `release-X.X` + +```erlang +{ + unreleased, + {release, Major :: non_neg_integer(), Minor :: non_neg_integer()} +} +``` + +## Should have + +### Handle shadowed BIFs + +`spectrometer_scanner:scan_directory/1` counts unqualified atom calls as +`{erlang, Fun, Arity}` based only on erl_internal:bif/2, which returns true for +compiler-recognized auto-imported BIFs without resolving shadowing. Using +`-compile({no_auto_import, [...]})` plus a local function definition causes a +bare call like length(X) to resolve to the local function instead of the BIF, +but the code will still count it as an OTP call, misclassifying user-defined +functions and skewing scan results. + +### `supported` modules + +The `supported` command should print a list of all AtomVM modules if the `-m` +or `--module` option is given without a module name. + +### Finer platform support tracking + +The tracking of platform support is not perfect. Some modules, like `network` +end up being assigned too broad of platform support, in this case including +`generic_unix` in the supported platforms, due to modules being assigned by +library so all modules in `avm_network` are reported as supported by `esp32`, +`generic_unix`, and `rp2` platforms. The `network` module is not supported on +`generic_unix`, only `esp32` and `rp2`, but to track these exceptions specific +filtering rules will be needed. + +The version added data should be tracked per-platform. For example `i2c` and +`spi` added support for `rp2` and `stm32` platforms in version 0.7.0, while +`esp32` had support in 0.5.0 and the `supported` command reports support for +all platforms, and reports 0.5.0 as the release these functions were introduced +(inaccurate for `rp2` and `stm32` platforms). The data storage format needs to +be altered to track support for each platform, with `all` only requiring a +single entry with the version. + +#### Track when modules or functions are deprecated and removed + +The supported functions data should track when modules are deprecated, and +also when they are removed. Some new data structure will need to be devised, +either a new field entirely, or expanding the `since` which will also be +holding platform specific release introductions. The deprecation and removal +releases may need to be hard-coded into the application, as these are rare, and +parsing doc strings could potentially lead to false positives. + +### Add support for adding (and reporting) downstream drivers and libraries + +The `update` command should have an option for adding downstream drivers or +libraries supporting AtomVM. These entries should be marked in a way that when +reporting with the `supported` command they clearly indicate the dependency +required for support. One possible storage strategy would be to put the +application or repository name (i.e. `atomvm_lib`) in a tuple with the module +name in the `supported_functions.data` file. This would leave AtomVM native +supported functions as bare atoms, and downstream libraries as +`{Library, Module}`. The downstream option should take optional platform and +AtomVM version parameters, defaulting to `all` platforms and unknown for the +AtomVM release. + +## Would be nice + +### Use logger with configurable levels + +Logger should be used instead of `io:format/2` for log messages. A configurable +log file should be used, defaulting to a log file in the users cache directory +that is overwritten on each run. The log level should be configurable, as well +as the option for changing the log file name and location. + +#### Refactor error handling and logging + +Errors should be refactored to return atom() "reasons", and the conversion to +log messages should be handled by dispatch to an error logger. + +### Reusable APIs + +Most modules should be refactored to better separate logic and IO (reporting +and file operations). All user facing reporting should be consolidated into +`spectrometer_reporter.erl` and pure outputs should be returned from command +logic functions. diff --git a/generate_fun_data.sh b/generate_fun_data.sh new file mode 100755 index 0000000..551a9e1 --- /dev/null +++ b/generate_fun_data.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# generate_fun_data.sh +# +# Regenerates supported_functions.data with version information +# by scanning each AtomVM release tag and branch. +# +# Usage: ./generate_fun_data.sh +# +# SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) +# SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later + +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +ATOMVM_DIR="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SPECTROMETER="$SCRIPT_DIR/_build/default/bin/spectrometer" +TMP_CACHE_DIR="${TMPDIR:-/tmp}/spectrometer_version_cache.$$" + +if [ ! -d "$ATOMVM_DIR/.git" ]; then + echo "Error: $ATOMVM_DIR does not appear to be a valid AtomVM git repository" + exit 1 +fi + +echo "=== AtomVM Version Data Regeneration ===" +echo "Spectrometer: $SPECTROMETER" +echo "AtomVM directory: $ATOMVM_DIR" +echo "Cache directory: $TMP_CACHE_DIR" +echo "" + +# Step 1: (re)build the escript +echo "=== Building atomvm_spectrometer escript ===" +cd "$SCRIPT_DIR" +rebar3 escriptize +echo "" + +# Step 2: cd into AtomVM local checkout directory +cd "$ATOMVM_DIR" + +# Step 3: Sync latest changes +echo "=== Syncing AtomVM repository ===" +git switch main --quiet 2>/dev/null || { echo "Warning: Could not switch to main"; } +git pull || echo "Warning: git pull failed, continuing anyway" +git fetch --tags || echo "Warning: git fetch --tags failed, continuing anyway" +echo "" + +# Create cache directory and output file path +rm -rf "${TMP_CACHE_DIR}" +mkdir -p "${TMP_CACHE_DIR}" +OUTPUT_FILE="${TMP_CACHE_DIR}/supported_functions.data" + +# Step 4: Scan main branch (stored as {unreleased, <<"main">>}) +echo "=== Scanning branch: main (stored as {unreleased, \"main\"}) ===" +if git checkout "main" --quiet 2>/dev/null; then + "$SPECTROMETER" update \ + --atomvm-dir "$ATOMVM_DIR" \ + --branch "main" \ + --cache "$TMP_CACHE_DIR" \ + --output "$OUTPUT_FILE" \ + --force \ + --no-tests + echo "" +else + echo "Warning: Could not checkout main" +fi + +# Step 5: Scan release-0.7 branch (stored as {unreleased, <<"0.7.x">>}) +echo '=== Scanning branch: release-0.7 (stored as {unreleased, <<"0.7.x">>}) ===' +git fetch origin --quiet 2>/dev/null +if git show-ref --verify --quiet refs/remotes/origin/release-0.7; then + if git checkout "release-0.7" --quiet 2>/dev/null; then + "$SPECTROMETER" update \ + --atomvm-dir "$ATOMVM_DIR" \ + --branch "release-0.7" \ + --cache "$TMP_CACHE_DIR" \ + --output "$OUTPUT_FILE" \ + --force \ + --no-tests + echo "" + else + echo "Warning: Failed to checkout release-0.7, skipping..." + fi +else + echo "Warning: Could not checkout release-0.7" +fi + +# Step 6: Define tags to scan (in reverse-chronological order) +TAGS=( + "v0.7.0-alpha.1" + "v0.6.6" + "v0.6.5" + "v0.6.4" + "v0.6.3" + "v0.6.2" + "v0.6.1" + "v0.6.0" + "v0.5.0" +) + +# Step 7: Scan each tag (release is derived automatically from --tag) +for TAG in "${TAGS[@]}"; do + echo "=== Scanning tag: $TAG ===" + git checkout "$TAG" --quiet 2>/dev/null || { echo "Warning: Could not checkout $TAG"; continue; } + "$SPECTROMETER" update \ + --atomvm-dir "$ATOMVM_DIR" \ + --tag "$TAG" \ + --cache "$TMP_CACHE_DIR" \ + --output "$OUTPUT_FILE" \ + --force \ + --no-tests + echo "" +done + +# Step 8: Copy result to project priv/ +DEST_FILE="$SCRIPT_DIR/priv/supported_functions.data" +if [ ! -f "$OUTPUT_FILE" ]; then + echo "Error: Generated file not found at $OUTPUT_FILE" + exit 1 +fi +echo "=== Copying result to $SCRIPT_DIR/priv/supported_functions.data ===" +mkdir -p "$SCRIPT_DIR/priv" +# Check if files are the same (same inode) +if [ "$OUTPUT_FILE" -ef "$DEST_FILE" ]; then + echo "Files are already the same, no copy needed" +else + if [ -f "$DEST_FILE" ]; then + BACKUP_TS="$(date +%Y%m%d%H%M)" + echo "Backing up existing $DEST_FILE to ${DEST_FILE}.${BACKUP_TS}.bak" + mv "${DEST_FILE}" "${DEST_FILE}.${BACKUP_TS}.bak" + fi + cp "$OUTPUT_FILE" "$DEST_FILE" +fi + +if [ -d "$TMP_CACHE_DIR" ]; then + rm -rf "$TMP_CACHE_DIR" +fi + +echo "" +echo "Done! Version data written to $DEST_FILE" diff --git a/include/ecosystem.hrl b/include/ecosystem.hrl new file mode 100644 index 0000000..f481afb --- /dev/null +++ b/include/ecosystem.hrl @@ -0,0 +1,14 @@ +%% +%% Copyright (c) 2026 Winford (UncleGrumpy) +%% +%% This is part of atomvm_spectrometer +%% +%% SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) +%% SPDX-License-Identifier: Apache-2.0 + +-ifndef(ECOSYSTEM_HRL). +-define(ECOSYSTEM_HRL, true). + +-define(ECOSYSTEM_STATE, "beam_ecosystem.bin"). + +-endif. diff --git a/priv/supported_functions.data b/priv/supported_functions.data new file mode 100644 index 0000000..f52dee9 --- /dev/null +++ b/priv/supported_functions.data @@ -0,0 +1,2412 @@ +%% 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] +%% Since: binary version string like <<"v0.5.0">> or {unreleased, <<"0.7.x">>} +%% +%% SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) +%% SPDX-License-Identifier: Apache-2.0 + +[ + {ahttp_client, [ + {close, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {connect, 4, all, <<118, 48, 46, 54, 46, 51>>}, + {recv, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {request, 5, all, <<118, 48, 46, 54, 46, 51>>}, + {stream, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {stream_request_body, 3, all, <<118, 48, 46, 54, 46, 51>>} + ]}, + {alisp, [ + {booleanize, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {eval, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {run, 1, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {alisp_stdlib, [ + {append, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {binaryp, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {car, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {cdr, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {cons, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {floatp, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {identity, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {integerp, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {last, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {listp, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {mapcar, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {numberp, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {pidp, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {print, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {refp, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {tuple, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {tuplep, 1, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {application, [ + {get_env, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {get_env, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {arepl, [{start, 0, all, <<118, 48, 46, 53, 46, 48>>}]}, + {atomvm, [ + {add_avm_pack_binary, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {add_avm_pack_file, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {close_avm_pack, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {get_creation, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {get_start_beam, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {platform, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {posix_clock_settime, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_closedir, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {posix_fstat, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_fsync, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_ftruncate, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_mkdir, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_mkfifo, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_open, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_open, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_opendir, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {posix_pread, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_pwrite, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_read, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_readdir, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {posix_rename, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_rmdir, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_seek, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_select_read, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_select_stop, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_select_write, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_stat, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_tcflush, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_tcgetattr, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_tcsetattr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {posix_unlink, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {posix_write, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {rand_bytes, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {random, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {read_priv, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {subprocess, 4, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {avm_pubsub, [ + {handle_call, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {pub, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {sub, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {unsub, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {unsub, 3, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {base64, [ + {decode, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {decode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {decode_to_string, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {decode_to_string, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {encode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_to_string, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {encode_to_string, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {binary, [ + {at, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {copy, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {copy, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {decode_hex, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {encode_hex, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {encode_hex, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {first, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {last, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {list_to_bin, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {longest_common_prefix, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {match, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {match, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {part, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {replace, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {replace, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {split, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {split, 3, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {calendar, [ + {date_to_gregorian_days, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {date_to_gregorian_days, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {datetime_to_gregorian_seconds, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {day_of_the_week, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {day_of_the_week, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {system_time_to_universal_time, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {code, [ + {all_available, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {all_loaded, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {ensure_loaded, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {get_object_code, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {is_loaded, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {load_abs, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {load_binary, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {which, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {code_server, [ + {atom_resolver, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {code_change, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {code_chunk, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {import_resolver, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {is_loaded, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {literal_resolver, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {resume, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_native_code, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {type_resolver, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {console, [ + {flush, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {flush, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {print, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {puts, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {puts, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 0, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {crypto, [ + {compute_key, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {crypto_final, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {crypto_init, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {crypto_init, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {crypto_one_time, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {crypto_one_time, 5, all, <<118, 48, 46, 54, 46, 48>>}, + {crypto_one_time_aead, 6, all, <<118, 48, 46, 55, 46, 48>>}, + {crypto_one_time_aead, 7, all, <<118, 48, 46, 55, 46, 48>>}, + {crypto_update, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {generate_key, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {hash, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {hash_equals, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {hash_final, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {hash_init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {hash_update, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {info_lib, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {mac, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {mac_final, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {mac_init, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mac_update, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {pbkdf2_hmac, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {sign, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {strong_rand_bytes, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {verify, 5, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {dist_util, [ + {cancel_timer, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handshake_other_started, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handshake_we_started, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {net_ticker_spawn_options, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {reset_timer, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {shutdown, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shutdown, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {start_timer, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {emscripten, [ + {promise_reject, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {promise_reject, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {promise_resolve, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {promise_resolve, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_blur_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_blur_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_blur_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_click_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_click_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_click_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_dblclick_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_dblclick_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_dblclick_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_focus_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_focus_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_focus_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_focusin_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_focusin_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_focusin_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_focusout_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_focusout_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_focusout_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_keydown_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_keydown_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_keydown_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_keypress_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_keypress_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_keypress_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_keyup_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_keyup_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_keyup_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mousedown_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mousedown_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mousedown_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseenter_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseenter_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseenter_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseleave_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseleave_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseleave_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mousemove_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mousemove_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mousemove_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseout_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseout_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseout_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseover_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseover_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseover_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseup_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseup_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_mouseup_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_resize_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_resize_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_resize_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_scroll_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_scroll_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_scroll_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchcancel_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchcancel_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchcancel_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchend_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchend_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchend_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchmove_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchmove_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchmove_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchstart_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchstart_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_touchstart_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {register_wheel_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {register_wheel_callback, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {register_wheel_callback, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {run_script, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {run_script, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_blur_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_click_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_dblclick_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_focus_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_focusin_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_focusout_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_keydown_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_keypress_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_keyup_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_mousedown_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_mouseenter_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_mouseleave_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_mousemove_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_mouseout_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_mouseover_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_mouseup_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_resize_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_scroll_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_touchcancel_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_touchend_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_touchmove_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_touchstart_callback, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {unregister_wheel_callback, 1, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {epmd, [ + {handle_call, 3, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 2, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {handle_info, 2, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {init, 1, [esp32, generic_unix, rp2], <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {terminate, 2, [esp32, generic_unix, rp2], <<118, 48, 46, 55, 46, 48>>} + ]}, + {erl_epmd, [ + {code_change, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 2, 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>>}, + {names, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {port_please, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {register_node, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {erlang, [ + {'*', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'+', 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'+', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'-', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'-', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'/', 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'<', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'=:=', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'=<', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'==', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'>', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'>=', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {abs, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'and', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {apply, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {apply, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {atom_to_binary, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {atom_to_binary, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {atom_to_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'band', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {binary_part, 3, all, <<118, 48, 46, 54, 46, 54>>}, + {binary_to_atom, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {binary_to_atom, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {binary_to_existing_atom, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {binary_to_existing_atom, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {binary_to_float, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {binary_to_integer, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {binary_to_integer, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {binary_to_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {binary_to_term, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {binary_to_term, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {bit_size, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'bnot', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {'bor', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'bsl', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'bsr', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'bxor', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {byte_size, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {cancel_timer, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {ceil, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {crc32, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {crc32, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {crc32_combine, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {delete_element, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {demonitor, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {demonitor, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {display, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {display_string, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {display_string, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dist_ctrl_get_data, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {dist_ctrl_get_data_notification, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {dist_ctrl_put_data, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {'div', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {element, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {erase, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {erase, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {error, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {error, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {error, 3, all, <<118, 48, 46, 54, 46, 52>>}, + {exit, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {exit, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {float, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {float_to_binary, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {float_to_binary, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {float_to_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {float_to_list, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {floor, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {fun_info, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {fun_info, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {fun_to_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {function_exported, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {garbage_collect, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {garbage_collect, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {get, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {get, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {get_cookie, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {get_cookie, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {get_module_info, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {get_module_info, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {group_leader, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {group_leader, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {hd, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {insert_element, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {integer_to_binary, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {integer_to_binary, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {integer_to_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {integer_to_list, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {iolist_size, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {iolist_to_binary, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_alive, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {is_atom, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_binary, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_bitstring, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {is_boolean, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {is_float, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {is_function, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {is_function, 2, all, <<118, 48, 46, 54, 46, 54>>}, + {is_integer, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_integer, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {is_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_map, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_map_key, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {is_number, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_pid, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_port, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {is_process_alive, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_record, 2, all, <<118, 48, 46, 54, 46, 54>>}, + {is_reference, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {is_tuple, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {length, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {link, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {list_to_atom, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {list_to_binary, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {list_to_bitstring, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {list_to_existing_atom, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {list_to_float, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {list_to_integer, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {list_to_integer, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {list_to_tuple, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {loaded, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {localtime, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {localtime, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {make_fun, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {make_ref, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {make_tuple, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {map_get, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {map_is_key, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {map_size, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {max, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {md5, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {memory, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {min, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {module_loaded, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {monitor, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {monotonic_time, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {monotonic_time, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_error, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {node, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {node, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {'not', 1, all, <<118, 48, 46, 53, 46, 48>>}, + {open_port, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'or', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {pid_to_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {port_to_list, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {process_flag, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {process_flag, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {process_info, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {processes, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {put, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {raise, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {ref_to_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {register, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {'rem', 2, all, <<118, 48, 46, 53, 46, 48>>}, + {round, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {self, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {send, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {send_after, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {set_cookie, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {set_cookie, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {setelement, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {setnode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {setnode, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {size, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {spawn, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {spawn, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {spawn_link, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {spawn_link, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {spawn_monitor, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {spawn_monitor, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {spawn_opt, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {spawn_opt, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {split_binary, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_timer, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {start_timer, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {system_flag, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {system_info, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {system_time, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {system_time, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {term_to_binary, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {throw, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {timestamp, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {tl, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {trunc, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {tuple_size, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {tuple_to_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {unique_integer, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {unique_integer, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {universaltime, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {unlink, 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>>}, + {'xor', 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {erpc, [{execute_call, 4, all, <<118, 48, 46, 55, 46, 48>>}]}, + {erts_debug, [{flat_size, 1, all, <<118, 48, 46, 53, 46, 48>>}]}, + {erts_internal, [{cmp_term, 2, all, <<118, 48, 46, 55, 46, 48>>}]}, + {esp, [ + {deep_sleep, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {deep_sleep, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {deep_sleep_enable_gpio_wakeup, 2, all, <<118, 48, 46, 54, 46, 50>>}, + {freq_hz, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {get_default_mac, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {get_mac, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {light_sleep, 0, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {mount, 4, all, <<118, 48, 46, 54, 46, 53>>}, + {nvs_erase_all, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {nvs_erase_all, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {nvs_erase_key, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {nvs_erase_key, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {nvs_fetch_binary, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nvs_get_binary, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {nvs_get_binary, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {nvs_get_binary, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {nvs_put_binary, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {nvs_reformat, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {nvs_set_binary, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {nvs_set_binary, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {partition_erase_range, 2, [esp32], <<118, 48, 46, 54, 46, 48>>}, + {partition_erase_range, 3, [esp32], <<118, 48, 46, 54, 46, 48>>}, + {partition_list, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {partition_mmap, 3, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {partition_read, 3, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {partition_write, 3, [esp32], <<118, 48, 46, 54, 46, 48>>}, + {reset_reason, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {restart, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {rtc_slow_get_binary, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {rtc_slow_set_binary, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {sleep_disable_ext1_wakeup_io, 1, all, <<118, 48, 46, 54, 46, 50>>}, + {sleep_enable_ext0_wakeup, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {sleep_enable_ext1_wakeup, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {sleep_enable_ext1_wakeup_io, 2, all, <<118, 48, 46, 54, 46, 50>>}, + {sleep_enable_gpio_wakeup, 0, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {sleep_enable_timer_wakeup, 1, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {sleep_enable_ulp_wakeup, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {sleep_get_wakeup_cause, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {sleep_ulp_wakeup, 0, [esp32], <<118, 48, 46, 54, 46, 48>>}, + {task_wdt_add_user, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {task_wdt_deinit, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {task_wdt_delete_user, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {task_wdt_init, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {task_wdt_reconfigure, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {task_wdt_reset_user, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {timer_get_time, 0, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {umount, 1, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {esp32devmode, [ + {erase_net_config, 0, [esp32], <<118, 48, 46, 54, 46, 48>>}, + {handle_req, 3, [esp32], <<118, 48, 46, 54, 46, 48>>}, + {save_net_config, 2, [esp32], <<118, 48, 46, 54, 46, 48>>}, + {start_dev_mode, 0, [esp32], <<118, 48, 46, 54, 46, 48>>}, + {start_network, 0, [esp32], <<118, 48, 46, 54, 46, 48>>}, + {start_repl, 1, [esp32], <<118, 48, 46, 54, 46, 48>>} + ]}, + {esp_adc, [ + {acquire, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {acquire, 4, all, <<118, 48, 46, 54, 46, 53>>}, + {deinit, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {handle_call, 3, all, <<118, 48, 46, 54, 46, 53>>}, + {handle_cast, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {handle_info, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {init, 0, all, <<118, 48, 46, 54, 46, 53>>}, + {init, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {read, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {read, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {release_channel, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {sample, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {sample, 3, all, <<118, 48, 46, 54, 46, 53>>}, + {start, 0, all, <<118, 48, 46, 54, 46, 53>>}, + {start, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {start, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {stop, 0, all, <<118, 48, 46, 54, 46, 53>>}, + {stop, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {terminate, 2, all, <<118, 48, 46, 54, 46, 53>>} + ]}, + {esp_dac, [ + {new_channel, 2, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {oneshot_del_channel, 1, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {oneshot_new_channel_p, 1, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {oneshot_output_voltage, 2, [esp32], <<118, 48, 46, 55, 46, 48>>} + ]}, + {etest, [ + {assert_equals, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {assert_exception, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {assert_exception, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {assert_exception, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {assert_failure, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {assert_match, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {assert_true, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {flush_msg_queue, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {test, 1, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {ets, [ + {delete, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {delete, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {delete_object, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {insert, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {insert_new, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lookup, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lookup_element, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {lookup_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {member, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {take, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {update_counter, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {update_counter, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {update_element, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {update_element, 4, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {eunit, [ + {start, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {test, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {test, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {file, [ + {get_cwd, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {native_name_encoding, 0, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {filename, [ + {join, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {split, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {gen, [ + {call, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {cast, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {reply, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {gen_event, [ + {add_handler, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {delete_handler, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {notify, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {start, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {start, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {start_link, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {start_link, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {stop, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {sync_notify, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {gen_server, [ + {call, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {call, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {cast, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {init_it, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {init_it, 5, all, <<118, 48, 46, 54, 46, 48>>}, + {loop, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {reply, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {start_link, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {start_link, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {start_monitor, 3, all, <<118, 48, 46, 54, 46, 53>>}, + {start_monitor, 4, all, <<118, 48, 46, 54, 46, 53>>}, + {stop, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {stop, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {system_code_change, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {system_continue, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {system_get_state, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {system_terminate, 4, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {gen_statem, [ + {call, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {call, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {cast, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {reply, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {start_link, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {start_link, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {stop, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {stop, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {gen_tcp, [ + {accept, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {accept, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {close, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {connect, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {controlling_process, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {listen, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {recv, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {recv, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {send, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {gen_tcp_inet, [ + {accept, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {accept, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {connect, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {controlling_process, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {listen, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {peername, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {port, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {send, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {sockname, 1, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {gen_tcp_socket, [ + {accept, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {accept, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {connect, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {controlling_process, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {listen, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {peername, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {port, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {send, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {sockname, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {gen_udp, [ + {close, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {controlling_process, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {open, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {open, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {recv, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {recv, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {send, 4, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {gen_udp_inet, [ + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {controlling_process, 2, 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>>}, + {port, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {send, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {sockname, 1, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {gen_udp_socket, [ + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {controlling_process, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {init, 1, 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>>}, + {port, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {send, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {sockname, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {gpio, [ + {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, 53, 46, 48>>}, + {deep_sleep_hold_en, 0, all, <<118, 48, 46, 53, 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, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {hold_dis, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {hold_en, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {open, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {read, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {remove_int, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {set_direction, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {set_function, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {set_int, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {set_int, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {set_level, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {set_pin_mode, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {set_pin_pull, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {wakeup_enable, 2, [esp32], <<118, 48, 46, 55, 46, 48>>} + ]}, + {http_server, [ + {parse_query_string, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {reply, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {reply, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {start_server, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {i2c, [ + {begin_transmission, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {begin_transmission_nif, 2, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {close_nif, 1, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {deinit, 1, [stm32], <<118, 48, 46, 55, 46, 48>>}, + {end_transmission, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {end_transmission_nif, 1, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {enqueue_write_bytes_nif, 2, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {get_read_available, 1, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {get_write_available, 1, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {init, 1, [stm32], <<118, 48, 46, 55, 46, 48>>}, + {init, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {is_device_ready, 4, [stm32], <<118, 48, 46, 55, 46, 48>>}, + {master_receive, 4, [stm32], <<118, 48, 46, 55, 46, 48>>}, + {master_transmit, 4, [stm32], <<118, 48, 46, 55, 46, 48>>}, + {mem_read, 6, [stm32], <<118, 48, 46, 55, 46, 48>>}, + {mem_write, 6, [stm32], <<118, 48, 46, 55, 46, 48>>}, + {open, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {open_nif, 1, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {read_blocking, 4, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {read_blocking_until, 5, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {read_burst_blocking, 3, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {read_bytes, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {read_bytes, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {read_bytes_nif, 2, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {read_raw_blocking, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {read_timeout_per_char_us, 5, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {read_timeout_us, 5, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {set_baudrate, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {set_slave_mode, 3, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {slave_receive, 3, [stm32], <<118, 48, 46, 55, 46, 48>>}, + {slave_transmit, 3, [stm32], <<118, 48, 46, 55, 46, 48>>}, + {write_blocking, 4, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {write_blocking_until, 5, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {write_burst_blocking, 3, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {write_byte, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {write_bytes, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {write_bytes, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {write_bytes, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {write_bytes_nif, 2, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {write_raw_blocking, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {write_timeout_per_char_us, 5, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {write_timeout_us, 5, [rp2], <<118, 48, 46, 55, 46, 48>>} + ]}, + {inet, [ + {close, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {getaddr, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {ntoa, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {parse_address, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {parse_ipv4_address, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {parse_ipv4strict_address, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {peername, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {port, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {sockname, 1, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {init, [ + {boot, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {get_argument, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {get_plain_arguments, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {notify_when_started, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {io, [ + {columns, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {columns, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {format, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {format, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {format, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {fwrite, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {fwrite, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {fwrite, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {get_line, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {getopts, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {getopts, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {printable_range, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {put_chars, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {put_chars, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {requests, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {scan_erl_exprs, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {setopts, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {setopts, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {io_lib, [ + {chars_length, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {format, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {fwrite, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {latin1_char_list, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {printable_list, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {write, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {write_atom, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {write_binary, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {write_string, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {write_string, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit, [ + {backend, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {backend_module, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {beam_chunk_header, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {compile, 7, all, <<118, 48, 46, 55, 46, 48>>}, + {compile, 8, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {decode_value64, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {is_small_integer_range, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {small_integer_bounds, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {stream, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {stream_module, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {variant, 0, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_aarch64, [ + {add, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {and_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {assert_all_native_free, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {available_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {call_func_ptr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_only_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_last, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_with_cp, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {continuation_entry_point, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {copy_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {debugger, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decrement_reductions_and_maybe_schedule_next, 1, all, + <<118, 48, 46, 55, 46, 48>>}, + {div_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_ctx_register, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_function, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_line, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_opcode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_variables, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {free_native_registers, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {get_array_element, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {get_module_index, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {if_block, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {if_else_block, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {increment_sp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_table, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_continuation, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_offset, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_cp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_vm_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mul, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {or_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {rem_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {return_if_not_equal_to_ctx, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {return_labels_and_lines, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_bs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_left, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right_arith, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stream, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {update_branches, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {used_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {word_size, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {xor_, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_aarch64_asm, [ + {add, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {add, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {adr, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {and_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {asr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {b, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {bcc, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {blr, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {br, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {brk, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {cbnz, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {cbnz_w, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {cmp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {cmp_w, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {eor, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {ldp, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {ldr, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {ldr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {ldr_w, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lsl, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {lsr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {madd, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {mov, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {movk, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {movz, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {msub, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {mul, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {nop, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {orr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {ret, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {sdiv, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stp, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {str, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {str, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {str_w, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {subs, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {tbnz, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {tbz, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {tst, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {tst_w, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_arm32, [ + {add, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {and_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {assert_all_native_free, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {available_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {call_func_ptr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_only_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_last, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_with_cp, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {continuation_entry_point, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {copy_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {debugger, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decrement_reductions_and_maybe_schedule_next, 1, all, + <<118, 48, 46, 55, 46, 48>>}, + {dwarf_ctx_register, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_function, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_line, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_opcode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_variables, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {free_native_registers, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {get_array_element, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {get_module_index, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {if_block, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {if_else_block, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {increment_sp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_table, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_continuation, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_offset, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_cp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_vm_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mul, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {or_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {return_if_not_equal_to_ctx, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {return_labels_and_lines, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_bs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_left, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right_arith, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stream, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {update_branches, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {used_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {word_size, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {xor_, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_arm32_asm, [ + {add, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {add, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {and_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {and_, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {asr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {asr, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {b, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {bic, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {bic, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {bkpt, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {blx, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {cmp, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_imm, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {eor, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {eor, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {ldr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {lsl, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {lsl, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {lsr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {lsr, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {mov, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {mov, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mul, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {mvn, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {orr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {orr, 4, 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>>}, + {reg_to_num, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {str, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {subs, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {subs, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {tst, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_armv6m, [ + {add, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {and_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {assert_all_native_free, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {available_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {call_func_ptr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_only_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_last, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_with_cp, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {continuation_entry_point, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {copy_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {debugger, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decrement_reductions_and_maybe_schedule_next, 1, all, + <<118, 48, 46, 55, 46, 48>>}, + {dwarf_ctx_register, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_function, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_line, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_opcode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_variables, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {free_native_registers, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {get_array_element, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {get_module_index, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {if_block, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {if_else_block, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {increment_sp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_table, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_continuation, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_offset, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_cp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_vm_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mul, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {or_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {return_if_not_equal_to_ctx, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {return_labels_and_lines, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_bs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_left, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right_arith, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stream, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {update_branches, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {used_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {word_size, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {xor_, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_armv6m_asm, [ + {add, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {add, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {adds, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {adds, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {adr, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {ands, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {asrs, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {b, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {bcc, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {bics, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {bkpt, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {blx, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {bx, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {cmp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {eors, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {ldr, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lsls, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lsls, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {lsrs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lsrs, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mov, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {movs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {muls, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {mvns, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {negs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {nop, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {orrs, 2, 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>>}, + {reg_to_num, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {rsbs, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {str, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {subs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {subs, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {tst, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_armv7m_asm, [ + {b_w, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {movt, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {movw, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_dwarf, [ + {append, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {elf, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {extract_x_reg_locations, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {function, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {line, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {map, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 6, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {opcode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {opcode, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {replace, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stream, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {variables, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}} + ]}, + {jit_dwarf_pt, [{parse_transform, 2, all, <<118, 48, 46, 55, 46, 48>>}]}, + {jit_precompile, [ + {atom_resolver, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {compile, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {import_resolver, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {type_resolver, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_regs, [ + {find_reg_with_contents, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {get_all_contents, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {get_contents, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {invalidate_all, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {invalidate_reg, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {invalidate_vm_loc, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {invalidate_volatile, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {merge, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {regs_to_mask, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_contents, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stack_clear, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {stack_contents, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {stack_pop, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {stack_push, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {unreachable, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {value_to_contents, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {vm_dest_to_contents, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_riscv32, [ + {add, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {and_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {assert_all_native_free, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {available_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {call_func_ptr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_only_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_last, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_with_cp, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {continuation_entry_point, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {copy_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {debugger, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decrement_reductions_and_maybe_schedule_next, 1, all, + <<118, 48, 46, 55, 46, 48>>}, + {div_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_ctx_register, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_function, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_line, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_opcode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_register_number, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_variables, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {free_native_registers, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {get_array_element, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {get_module_index, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {if_block, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {if_else_block, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {increment_sp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_table, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_continuation, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_offset, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_cp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_vm_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mul, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {or_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {rem_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {return_if_not_equal_to_ctx, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {return_labels_and_lines, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_bs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_left, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right_arith, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stream, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {update_branches, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {used_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {word_size, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {xor_, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_riscv32_asm, [ + {and_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {andi, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {auipc, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {bge, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {bgeu, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {blt, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {bltu, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {bne, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_i_type, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_j_type, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_s_type, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {is_compressed_reg, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {j, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jalr, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jalr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {jr, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {lb, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lb, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {lbu, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lbu, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {lh, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lh, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {lhu, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lhu, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {li, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {lw, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mv, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {neg, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {not_, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {or_, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {or_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {ori, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {reg_to_c_num, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {reg_to_num, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {ret, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {sb, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {sb, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sh, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {sh, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sll, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {slli, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {slt, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {slti, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sltiu, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sltu, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sra, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {srai, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {srl, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {srli, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sw, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {xor_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {xori, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_riscv64, [ + {add, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {and_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {assert_all_native_free, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {available_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {call_func_ptr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_only_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_last, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_with_cp, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {continuation_entry_point, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {copy_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {debugger, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decrement_reductions_and_maybe_schedule_next, 1, all, + <<118, 48, 46, 55, 46, 48>>}, + {div_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_ctx_register, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_function, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_line, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_opcode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_variables, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {free_native_registers, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {get_array_element, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {get_module_index, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {if_block, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {if_else_block, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {increment_sp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_table, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_continuation, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_offset, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_cp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_vm_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mul, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {or_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {rem_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {return_if_not_equal_to_ctx, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {return_labels_and_lines, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_bs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_left, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right_arith, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stream, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {update_branches, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {used_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {word_size, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {xor_, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_stream_binary, [ + {append, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {map, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {replace, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_stream_flash, [ + {append, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {map, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {read, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {replace, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_stream_mmap, [ + {append, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {map, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {read, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {replace, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_wasm32, [ + {add, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {add_label, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {add_label, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {and_, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {assert_all_native_free, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {available_regs, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {call_func_ptr, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {call_only_or_schedule_next, 2, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {call_or_schedule_next, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {call_primitive, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {call_primitive_last, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {call_primitive_with_cp, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {cond_jump_to_label, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {continuation_entry_point, 1, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {copy_to_native_register, 2, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {debugger, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {decrement_reductions_and_maybe_schedule_next, 1, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {div_reg, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {flush, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {free_native_registers, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {get_array_element, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {get_module_index, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {get_regs_tracking, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {if_block, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {if_else_block, 4, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {increment_sp, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {jump_table, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {jump_to_continuation, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {jump_to_label, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {jump_to_offset, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {move_array_element, 4, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {move_to_array_element, 4, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {move_to_array_element, 5, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {move_to_cp, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {move_to_native_register, 2, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {move_to_native_register, 3, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {move_to_vm_register, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {mul, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {new, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {offset, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {or_, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {rem_reg, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {return_if_not_equal_to_ctx, 2, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {return_labels_and_lines, 2, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {set_bs, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {set_continuation_to_label, 2, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {set_continuation_to_offset, 1, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {shift_left, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {shift_right, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {shift_right_arith, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {stream, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {sub, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {supports_tail_cache, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {update_branches, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {used_regs, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {word_size, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {xor_, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}} + ]}, + {jit_wasm32_asm, [ + {block, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {blocktype_i32, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {blocktype_void, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {br, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {br_if, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {br_table, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {call, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {call_indirect, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {decode_uleb128, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {else_, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_code_section, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_export_section, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_func_body, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_func_type, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_function_section, 1, all, + {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_name, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_sleb128, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_table_section, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_type_section, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {encode_vector, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {end_, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {global_get, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {global_set, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_add, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_and, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_clz, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_ctz, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_div_s, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_div_u, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_eq, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_ge_s, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_ge_u, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_gt_s, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_gt_u, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_le_s, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_le_u, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_load16_s, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_load16_u, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_load8_s, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_load8_u, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_lt_s, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_lt_u, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_mul, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_ne, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_or, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_rem_s, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_rem_u, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_shl, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_shr_s, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_shr_u, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_store, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_store16, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_store8, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_sub, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i32_xor, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {i64_const, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {if_, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {local_index, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {local_set, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {local_tee, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {loop, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {nop, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {return, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {type_externref, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {type_f32, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {type_f64, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {type_funcref, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {type_i64, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {wasm_magic, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {wasm_version, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}} + ]}, + {jit_x86_64, [ + {add, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {add_label, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {and_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {assert_all_native_free, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {available_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {call_func_ptr, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_only_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_or_schedule_next, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_last, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {call_primitive_with_cp, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {continuation_entry_point, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {copy_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {debugger, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decrement_reductions_and_maybe_schedule_next, 1, all, + <<118, 48, 46, 55, 46, 48>>}, + {div_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_ctx_register, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_function, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_line, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_opcode, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {dwarf_variables, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {flush, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {free_native_registers, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {get_array_element, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {get_module_index, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {if_block, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {if_else_block, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {increment_sp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_table, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_continuation, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jump_to_offset, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_array_element, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_cp, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_native_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {move_to_vm_register, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {mul, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {new, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {or_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {rem_, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {return_if_not_equal_to_ctx, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {return_labels_and_lines, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_bs, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_label, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {set_continuation_to_offset, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_left, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {shift_right_arith, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {stream, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {sub, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {update_branches, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {used_regs, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {word_size, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {xor_, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {jit_x86_64_asm, [ + {addq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {andb, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {andl, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {andq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {callq, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {cmpb, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {cmpl, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {cmpq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {cqo, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {decl, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {idivq, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {imulq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {jge, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jge_rel8, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jle, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jle_rel8, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jmp, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jmp_rel32, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jmp_rel8, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jmpq, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jnz, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jnz_rel8, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jz, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {jz_rel8, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {leaq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {leaq_rel32, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {movabsq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {movl, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {movq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {orq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {orq_rel32, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {popq, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {pushq, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {retq, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {sarq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {shlq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {shrq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {subq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {testb, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {testl, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {testq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {xchgq, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {xorl, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {xorq, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {json, [ + {decode, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {decode, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {decode_continue, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {decode_start, 3, 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_atom, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_binary, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_binary_escape_all, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_float, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_integer, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_key_value_list, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_key_value_list_checked, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_list, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_map, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_map_checked, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {encode_value, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {json_encoder, [{encode, 1, all, <<118, 48, 46, 53, 46, 48>>}]}, + {kernel, [ + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {ledc, [ + {channel_config, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {fade_func_install, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {fade_func_uninstall, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {fade_start, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {fade_stop, 2, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {get_duty, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {get_freq, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {set_duty, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {set_duty_and_update, 4, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {set_fade_step_and_start, 6, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {set_fade_time_and_start, 5, [esp32], <<118, 48, 46, 55, 46, 48>>}, + {set_fade_with_step, 5, all, <<118, 48, 46, 53, 46, 48>>}, + {set_fade_with_time, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {set_freq, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {stop, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {timer_config, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {update_duty, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {lists, [ + {all, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {any, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {append, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {append, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {delete, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {droplast, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {dropwhile, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {duplicate, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {filter, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {filtermap, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {flatmap, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {flatten, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {foldl, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {foldr, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {foreach, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {join, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {keydelete, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {keyfind, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {keymember, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {keyreplace, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {keysort, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {keystore, 4, all, <<118, 48, 46, 54, 46, 51>>}, + {keytake, 3, all, <<118, 48, 46, 54, 46, 51>>}, + {last, 1, all, <<118, 48, 46, 54, 46, 53>>}, + {map, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {mapfoldl, 3, all, <<118, 48, 46, 54, 46, 53>>}, + {max, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {member, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {merge, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {merge, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {min, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {nth, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {nthtail, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {reverse, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {reverse, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {search, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {seq, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {seq, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {sort, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {sort, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {split, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {sublist, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {sublist, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {ukeysort, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {usort, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {usort, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {zip, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {zipwith, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {logger, [ + {alert, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {alert, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {alert, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {allow, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {compare, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {console_log, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {critical, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {critical, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {critical, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {debug, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {debug, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {debug, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {emergency, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {emergency, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {emergency, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {error, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {error, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {error, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {get_filter, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {get_levels, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {get_sinks, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {info, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {info, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {info, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {log, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {log, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {log, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {loop, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {macro_log, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {macro_log, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {macro_log, 5, all, <<118, 48, 46, 54, 46, 48>>}, + {notice, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {notice, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {notice, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {set_filter, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {set_levels, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {set_sinks, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {warning, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {warning, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {warning, 3, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {logger_manager, [ + {allow, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {get_handlers, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {get_id, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {logger_std_h, [{log, 2, all, <<118, 48, 46, 54, 46, 48>>}]}, + {maps, [ + {filter, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {find, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {fold, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {foreach, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {from_keys, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {from_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {get, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {get, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {is_key, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {iterator, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {iterator, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {keys, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {map, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {merge, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {merge_with, 3, all, <<118, 48, 46, 54, 46, 53>>}, + {new, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {next, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {put, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {remove, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {size, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {to_list, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {update, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {values, 1, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {math, [ + {acos, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {acosh, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {asin, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {asinh, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {atan, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {atan2, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {atanh, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {ceil, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {cos, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {cosh, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {exp, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {floor, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {fmod, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {log, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {log10, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {log2, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {pi, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {pow, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {sin, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {sinh, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {sqrt, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {tan, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {tanh, 1, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {mdns, [ + {handle_call, 3, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 2, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {handle_info, 2, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {init, 1, [esp32, generic_unix, rp2], <<118, 48, 46, 55, 46, 48>>}, + {parse_dns_message, 1, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {parse_dns_name, 2, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {serialize_dns_message, 1, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {serialize_dns_name, 1, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {stop, 1, [esp32, generic_unix, rp2], <<118, 48, 46, 55, 46, 48>>}, + {terminate, 2, [esp32, generic_unix, rp2], <<118, 48, 46, 55, 46, 48>>} + ]}, + {net, [ + {getaddrinfo, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {getaddrinfo, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {getaddrinfo_nif, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {gethostname, 0, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {net_kernel, [ + {epmd_module, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {get_cookie, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {get_cookie, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {get_net_ticktime, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {get_state, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 2, 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>>}, + {mark_nodeup, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {mark_pending, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {set_cookie, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {set_cookie, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {net_kernel_sup, [ + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {network, [ + {handle_call, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_continue, 2, all, <<118, 48, 46, 54, 46, 54>>}, + {handle_info, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {sta_connect, 0, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {sta_connect, 1, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {sta_disconnect, 0, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {sta_rssi, 0, all, <<118, 48, 46, 54, 46, 50>>}, + {sta_status, 0, [esp32, generic_unix, rp2], + <<118, 48, 46, 55, 46, 48>>}, + {start, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {start_link, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_ap, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_ap, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_ap, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_sta, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_sta, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_sta, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {wifi_scan, 0, [esp32, generic_unix, rp2], + {unreleased, <<48, 46, 55, 46, 120>>}}, + {wifi_scan, 1, [esp32, generic_unix, rp2], + {unreleased, <<48, 46, 55, 46, 120>>}} + ]}, + {network_fsm, [ + {start, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_ap, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_ap, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_ap, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_sta, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_sta, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {wait_for_sta, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {os, [ + {getenv, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {system_time, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {system_time, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {pico, [ + {cyw43_arch_gpio_get, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {cyw43_arch_gpio_put, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {rtc_set_datetime, 1, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {port, [ + {call, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {call, 3, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {proc_lib, [ + {init_ack, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {init_ack, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {init_fail, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {init_fail, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {init_p, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {initial_call, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {spawn, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {spawn, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {spawn_link, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {spawn_link, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {start_monitor, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {start_monitor, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {start_monitor, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {translate_initial_call, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {proplists, [ + {compact, 1, all, <<118, 48, 46, 54, 46, 50>>}, + {delete, 2, all, <<118, 48, 46, 54, 46, 50>>}, + {from_map, 1, all, <<118, 48, 46, 54, 46, 50>>}, + {get_all_values, 2, all, <<118, 48, 46, 54, 46, 50>>}, + {get_bool, 2, all, <<118, 48, 46, 54, 46, 50>>}, + {get_value, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {get_value, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {is_defined, 2, all, <<118, 48, 46, 54, 46, 50>>}, + {lookup, 2, all, <<118, 48, 46, 54, 46, 50>>}, + {lookup_all, 2, all, <<118, 48, 46, 54, 46, 50>>}, + {property, 1, all, <<118, 48, 46, 54, 46, 50>>}, + {property, 2, all, <<118, 48, 46, 54, 46, 50>>}, + {to_map, 1, all, <<118, 48, 46, 54, 46, 50>>}, + {unfold, 1, all, <<118, 48, 46, 54, 46, 50>>} + ]}, + {queue, [ + {all, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {any, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {delete, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {delete_r, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {delete_with, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {delete_with_r, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {drop, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {drop_r, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {filter, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {filtermap, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {fold, 3, all, <<118, 48, 46, 54, 46, 51>>}, + {from_list, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {get, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {get_r, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {in, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {in_r, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {is_empty, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {is_queue, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {join, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {len, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {member, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {new, 0, all, <<118, 48, 46, 54, 46, 51>>}, + {out, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {out_r, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {peek, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {peek_r, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {reverse, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {split, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {to_list, 1, all, <<118, 48, 46, 54, 46, 51>>} + ]}, + {serial_dist, [ + {accept, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {accept_connection, 5, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {address, 0, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {close, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {listen, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {listen, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {select, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {setup, 5, all, {unreleased, <<48, 46, 55, 46, 120>>}} + ]}, + {serial_dist_controller, [ + {address, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {code_change, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {getll, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {getstat, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {handle_call, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {handle_cast, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {handle_info, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {handshake_complete, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {init, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {recv, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {scan_frame, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {send, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {send_preamble, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {setopts_post_nodeup, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {setopts_pre_nodeup, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {start, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {start, 3, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {supervisor, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {terminate, 2, all, {unreleased, <<48, 46, 55, 46, 120>>}}, + {tick, 1, all, {unreleased, <<48, 46, 55, 46, 120>>}} + ]}, + {sets, [ + {add_element, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {del_element, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {filter, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {filtermap, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {fold, 3, all, <<118, 48, 46, 54, 46, 51>>}, + {from_list, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {from_list, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {intersection, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {intersection, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {is_disjoint, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {is_element, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {is_empty, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {is_equal, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {is_set, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {is_subset, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {map, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {new, 0, all, <<118, 48, 46, 54, 46, 51>>}, + {new, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {size, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {subtract, 2, all, <<118, 48, 46, 54, 46, 51>>}, + {to_list, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {union, 1, all, <<118, 48, 46, 54, 46, 51>>}, + {union, 2, all, <<118, 48, 46, 54, 46, 51>>} + ]}, + {sexp_lexer, [{string, 1, all, <<118, 48, 46, 53, 46, 48>>}]}, + {sexp_parser, [{parse, 1, all, <<118, 48, 46, 53, 46, 48>>}]}, + {sexp_serializer, [{serialize, 1, all, <<118, 48, 46, 53, 46, 48>>}]}, + {socket, [ + {accept, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {accept, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {bind, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {connect, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {getopt, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {listen, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {listen, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_accept, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_recv, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_recvfrom, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_select_read, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_select_stop, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_send, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_sendto, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {open, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {peername, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {recvfrom, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {recvfrom, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {recvfrom, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {send, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {sendto, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {setopt, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {shutdown, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {sockname, 1, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {socket_dist, [ + {accept, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {accept_connection, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {address, 0, all, <<118, 48, 46, 55, 46, 48>>}, + {close, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {listen, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {listen, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {select, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {setup, 5, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {socket_dist_controller, [ + {address, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {code_change, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {getll, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {getstat, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {handshake_complete, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {recv, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {send, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {setopts_post_nodeup, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {setopts_pre_nodeup, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {start, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {supervisor, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {tick, 1, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {spi, [ + {abort, 1, [stm32], {unreleased, <<48, 46, 55, 46, 120>>}}, + {apply_device_config, 2, [stm32], + {unreleased, <<48, 46, 55, 46, 120>>}}, + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {deinit, 1, [rp2, stm32], <<118, 48, 46, 55, 46, 48>>}, + {get_baudrate, 1, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {get_error, 1, [stm32], {unreleased, <<48, 46, 55, 46, 120>>}}, + {get_state, 1, [stm32], {unreleased, <<48, 46, 55, 46, 120>>}}, + {init, 2, [rp2, stm32], <<118, 48, 46, 55, 46, 48>>}, + {is_busy, 1, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {is_readable, 1, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {is_writable, 1, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {open, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {read16_blocking, 3, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {read_at, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {read_at, 4, all, <<118, 48, 46, 54, 46, 48>>}, + {read_blocking, 3, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {receive_, 3, [stm32], {unreleased, <<48, 46, 55, 46, 120>>}}, + {set_baudrate, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {set_format, 4, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {set_slave, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {transmit, 3, [stm32], {unreleased, <<48, 46, 55, 46, 120>>}}, + {transmit_receive, 3, [stm32], {unreleased, <<48, 46, 55, 46, 120>>}}, + {write, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {write16_blocking, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {write_at, 4, all, <<118, 48, 46, 53, 46, 48>>}, + {write_at, 5, all, <<118, 48, 46, 54, 46, 48>>}, + {write_blocking, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {write_read, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {write_read16_blocking, 2, [rp2], <<118, 48, 46, 55, 46, 48>>}, + {write_read_blocking, 2, [rp2], <<118, 48, 46, 55, 46, 48>>} + ]}, + {ssl, [ + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {connect, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_close_notify, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_conf_authmode, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_conf_rng, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_config_defaults, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_config_init, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_ctr_drbg_init, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_ctr_drbg_seed, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_entropy_init, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_handshake_step, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_init, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_read, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_set_bio, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_set_hostname, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_setup, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {nif_write, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {recv, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {send, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {start, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {stop, 0, all, <<118, 48, 46, 54, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {string, [ + {find, 2, all, <<118, 48, 46, 54, 46, 53>>}, + {find, 3, all, <<118, 48, 46, 54, 46, 53>>}, + {jaro_similarity, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {length, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {split, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {split, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {to_lower, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {to_upper, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {trim, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {trim, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {supervisor, [ + {count_children, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {delete_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {restart_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_child, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {start_link, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {start_link, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {terminate, 2, 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>>} + ]}, + {sys, [ + {change_code, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {change_code, 5, all, <<118, 48, 46, 55, 46, 48>>}, + {debug_options, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {get_state, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {get_state, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {get_status, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {get_status, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_debug, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {handle_system_msg, 6, all, <<118, 48, 46, 55, 46, 48>>}, + {replace_state, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {replace_state, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {resume, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {resume, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {suspend, 1, all, <<118, 48, 46, 55, 46, 48>>}, + {suspend, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {terminate, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {trace, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {trace, 3, all, <<118, 48, 46, 55, 46, 48>>} + ]}, + {timer, [ + {apply_after, 4, all, <<118, 48, 46, 55, 46, 48>>}, + {send_after, 2, all, <<118, 48, 46, 55, 46, 48>>}, + {send_after, 3, all, <<118, 48, 46, 55, 46, 48>>}, + {sleep, 1, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {timer_manager, [ + {cancel_timer, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {get_timer_refs, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_call, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_cast, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {handle_info, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {init, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {run_timer, 5, all, <<118, 48, 46, 53, 46, 48>>}, + {send_after, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {send_after_timer, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {start, 0, all, <<118, 48, 46, 53, 46, 48>>}, + {start_timer, 3, all, <<118, 48, 46, 53, 46, 48>>}, + {terminate, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {timestamp_util, [ + {delta, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {delta_ms, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {uart, [ + {close, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {open, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {open, 2, all, <<118, 48, 46, 53, 46, 48>>}, + {read, 1, all, <<118, 48, 46, 53, 46, 48>>}, + {read, 2, all, <<118, 48, 46, 54, 46, 54>>}, + {write, 2, all, <<118, 48, 46, 53, 46, 48>>} + ]}, + {unicode, [ + {characters_to_binary, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {characters_to_binary, 2, all, <<118, 48, 46, 54, 46, 48>>}, + {characters_to_binary, 3, all, <<118, 48, 46, 54, 46, 48>>}, + {characters_to_list, 1, all, <<118, 48, 46, 54, 46, 48>>}, + {characters_to_list, 2, all, <<118, 48, 46, 54, 46, 48>>} + ]}, + {websocket, [ + {buffered_amount, 1, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {close, 1, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {close, 2, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {close, 3, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {controlling_process, 2, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {extensions, 1, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {is_supported, 0, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {new, 1, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {new, 2, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {new, 3, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {protocol, 1, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {ready_state, 1, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {send_binary, 2, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {send_utf8, 2, [emscripten], <<118, 48, 46, 55, 46, 48>>}, + {url, 1, [emscripten], <<118, 48, 46, 55, 46, 48>>} + ]}, + {zlib, [{compress, 1, all, <<118, 48, 46, 55, 46, 48>>}]} +]. diff --git a/rebar.config b/rebar.config new file mode 100644 index 0000000..cdce6ef --- /dev/null +++ b/rebar.config @@ -0,0 +1,87 @@ +%% +%% 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 +{minimum_otp_vsn, "27"}. + +{erl_opts, [debug_info, {i, "include"}]}. +{deps, []}. + +{escript_incl_apps, [spectrometer]}. +{escript_main_app, spectrometer}. +{escript_name, spectrometer}. +{escript_emu_args, "%%! +sbtu +A1\n"}. +{escript_incl_extra, []}. + +{project_plugins, [erlfmt]}. + +{erlfmt, [ + write, + {print_width, 80}, + {files, [ + "rebar.config{,.script}", + "src/**/*.{hrl,erl,app.src}", + "priv/supported_functions.data", + "test/**/*.{hrl,erl}" + ]} +]}. + +{dialyzer, [ + {plt_extra_apps, [ + kernel, stdlib, compiler, syntax_tools, inets, ssl, crypto, public_key + ]}, + {warnings, [error_handling, unmatched_returns]} +]}. + +{xref_ignores, [ + {spectrometer, main, 1}, + {spectrometer_reporter, generate_report, 1}, + {spectrometer_reporter, print_summary, 1}, + {spectrometer_reporter, write_csv, 3} +]}. + +{profiles, [ + {test, [ + {erl_opts, [ + {i, "include"}, + debug_info, + {d, 'TEST', true}, + export_all, + nowarn_export_all, + warnings_as_errors + ]}, + {cover_enabled, true}, + {cover_opts, [verbose]}, + {cover_excl_mods, [spectrometer]}, + {eunit_opts, [{cover, true}, {scale_timeouts, 20}, {dir, test}]} + ]}, + {doc, [ + {erl_opts, [debug_info]}, + {plugins, [rebar3_ex_doc]}, + {hex, [ + {doc, #{provider => ex_doc}} + ]}, + {ex_doc, [ + {source_url, + <<"https://github.com/UncleGrumpy/atomvm_spectrometer">>}, + {homepage_url, + <<"https://UncleGrumpy.github.io/atomvm_spectrometer">>}, + {extras, [ + <<"README.md">>, + %% TODO: create these files + % <<"CHANGELOG.md">>, + % <<"CONTRIBUTING.md">>, + % <<"CODE_OF_CONDUCT.md">>, + <<"LICENSE">>, + <<"TODO.md">> + ]}, + {main, <<"README.md">>}, + {output, "doc"}, + {api_reference, true} + ]} + ]} +]}. diff --git a/rebar.lock b/rebar.lock new file mode 100644 index 0000000..57afcca --- /dev/null +++ b/rebar.lock @@ -0,0 +1 @@ +[]. diff --git a/rebar.lock.license b/rebar.lock.license new file mode 100644 index 0000000..acf6d62 --- /dev/null +++ b/rebar.lock.license @@ -0,0 +1,7 @@ +%% +%% Copyright (c) 2026 Winford (UncleGrumpy) +%% +%% This is part of atomvm_spectrometer +%% +SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) +SPDX-License-Identifier: Apache-2.0 diff --git a/src/atomvm_spectrometer.erl b/src/atomvm_spectrometer.erl new file mode 100644 index 0000000..519e402 --- /dev/null +++ b/src/atomvm_spectrometer.erl @@ -0,0 +1,527 @@ +%% +%% 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(atomvm_spectrometer). + +-moduledoc """ +Main entry point for the atomvm_spectrometer application. + +This module is the primary user-facing interface that orchestrates all CLI +commands. It handles argument parsing, command dispatch, and coordination +of audit, ecosystem, supported, filter, update, and query operations. +""". + +-export([main/1]). + +-export_type([opts_map/0]). + +-type parse_arg_result() :: {error, string()} | opts_map(). + +-type command_name() :: + audit | ecosystem | examine | supported | filter | update | query. + +-type opts_map() :: #{atom() => term()}. + +-doc """ +Entry point for the CLI. + +Parses the given arguments, dispatches to the appropriate command handler, +and terminates the process. In test mode (`TEST=true`), returns `ok` or +`{error, {halt, Code}}` instead of calling `halt/1`. +""". +-ifdef(TEST). +-spec main([string()]) -> ok | {error, {halt, non_neg_integer()}}. +-else. +-spec main([string()]) -> no_return(). +-endif. +main(Args) -> + case parse_args(Args) of + {error, Msg} -> + io:format(standard_error, "Error: ~s\n", [Msg]), + spectrometer_help:usage(), + maybe_halt(1); + version -> + case spectrometer_utils:version() of + {error, Reason} -> + io:format("Unable to determine version: ~p\n", [Reason]), + maybe_halt(1); + Version -> + io:format("~s\n", [Version]), + maybe_halt(0) + end; + help -> + spectrometer_help:usage(), + maybe_halt(0); + {help, Cmd} -> + spectrometer_help:usage(Cmd), + maybe_halt(0); + {command, audit, Opts} -> + case run_analyzer_dispatch(Opts, fun run_audit/1) of + ok -> + maybe_halt(0); + {error, Reason} -> + io:format("Audit failed, ~p.\n", [Reason]), + maybe_halt(1) + end; + {command, ecosystem, Opts} -> + case run_ecosystem(Opts) of + ok -> + maybe_halt(0); + {error, Reason} -> + io:format("Ecosystem scanning failed, ~p.\n", [Reason]), + maybe_halt(1) + end; + {command, examine, Opts} -> + case run_analyzer_dispatch(Opts, fun run_examine/1) of + ok -> + maybe_halt(0); + {error, Reason} -> + io:format("Examine failed, ~p.\n", [Reason]), + maybe_halt(1) + end; + {command, supported, Opts} -> + case run_supported(Opts) of + ok -> maybe_halt(0); + {error, _} -> maybe_halt(1) + end; + {command, filter, Opts} -> + case run_filter(Opts) of + ok -> + maybe_halt(0); + {error, Reason} -> + io:format("Filter failed: ~p\n", [Reason]), + maybe_halt(1) + end; + {command, update, Opts} -> + case run_update(Opts) of + ok -> maybe_halt(0); + {error, _} -> maybe_halt(1) + end; + {command, query, Opts} -> + case run_query(Opts) of + ok -> maybe_halt(0); + {error, _} -> maybe_halt(1) + end + end. + +-doc false. +-ifdef(TEST). +-spec maybe_halt(non_neg_integer()) -> ok | {error, {halt, non_neg_integer()}}. +maybe_halt(0) -> + ok; +maybe_halt(Code) -> + {error, {halt, Code}}. +-else. +-spec maybe_halt(non_neg_integer()) -> no_return(). +maybe_halt(Code) -> + halt(Code). +-endif. + +-doc """ +Parse command-line arguments and return the command dispatch tuple. + +Returns `help`, `{help, Command}`, `{command, Command, Opts}`, or +`{error, Message}`. +""". +-spec parse_args([string()]) -> + {error, string()} + | version + | help + | {help, command_name()} + | {command, command_name(), opts_map()}. +parse_args([]) -> + help; +parse_args(["--help" | _]) -> + help; +parse_args(["-h" | _]) -> + help; +parse_args(["help" | Args]) -> + parse_help_args(Args); +parse_args(["--version" | Args]) -> + parse_version_args(Args); +parse_args(["version" | Args]) -> + parse_version_args(Args); +parse_args(["audit" | Rest]) -> + case lists:any(fun(E) -> lists:member(E, ["-h", "--help"]) end, Rest) of + false -> + case parse_audit_args(Rest, #{}) of + {error, Msg} -> {error, Msg}; + Opts when is_map(Opts) -> {command, audit, Opts} + end; + _ -> + {help, audit} + end; +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 + {error, Msg} -> {error, Msg}; + Opts when is_map(Opts) -> {command, ecosystem, Opts} + end; + _ -> + {help, ecosystem} + end; +parse_args(["examine" | Rest]) -> + case lists:any(fun(E) -> lists:member(E, ["-h", "--help"]) end, Rest) of + false -> + %% examine and audit have the same options + case parse_audit_args(Rest, #{}) of + {error, Msg} -> {error, Msg}; + Opts when is_map(Opts) -> {command, examine, Opts} + end; + _ -> + {help, examine} + end; +parse_args(["supported" | Rest]) -> + case lists:any(fun(E) -> lists:member(E, ["-h", "--help"]) end, Rest) of + false -> + case parse_supported_args(Rest, #{}) of + {error, Msg} -> {error, Msg}; + Opts when is_map(Opts) -> {command, supported, Opts} + end; + _ -> + {help, supported} + end; +parse_args(["filter" | Rest]) -> + case lists:any(fun(E) -> lists:member(E, ["-h", "--help"]) end, Rest) of + false -> + case parse_filter_args(Rest, #{}) of + {error, Msg} -> {error, Msg}; + Opts when is_map(Opts) -> {command, filter, Opts} + end; + _ -> + {help, filter} + end; +parse_args(["update" | Rest]) -> + case lists:any(fun(E) -> lists:member(E, ["-h", "--help"]) end, Rest) of + false -> + case parse_update_args(Rest, #{}) of + {error, Msg} -> {error, Msg}; + Opts when is_map(Opts) -> {command, update, Opts} + end; + _ -> + {help, update} + end; +parse_args(["query" | Rest]) -> + case lists:any(fun(E) -> lists:member(E, ["-h", "--help"]) end, Rest) of + false -> + case parse_query_args(Rest, #{}) of + {error, Msg} -> {error, Msg}; + Opts when is_map(Opts) -> {command, query, Opts} + end; + _ -> + {help, query} + end; +parse_args([Unknown | _]) -> + {error, "Unsupported command " ++ Unknown}. + +parse_help_args([Cmd | _]) -> + case Cmd of + "audit" -> {help, audit}; + "ecosystem" -> {help, ecosystem}; + "examine" -> {help, examine}; + "supported" -> {help, supported}; + "filter" -> {help, filter}; + "update" -> {help, update}; + "query" -> {help, query}; + _ -> {error, "Unknown command: " ++ Cmd} + end; +parse_help_args([]) -> + help. + +-spec parse_version_args(Args :: [string()]) -> version | help. +parse_version_args([]) -> + version; +parse_version_args(Args) -> + case lists:any(fun(E) -> lists:member(E, ["-h", "--help"]) end, Args) of + false -> version; + _ -> help + end. + +-spec parse_audit_args([string()], opts_map()) -> parse_arg_result(). +parse_audit_args([], #{target := _} = Opts) -> + Opts#{ + top => maps:get(top, Opts, 50), + min_count => maps:get(min_count, Opts, 1), + output => maps:get(output, Opts, undefined) + }; +parse_audit_args([], #{multi_file := _} = Opts) -> + Opts#{ + top => maps:get(top, Opts, 50), + min_count => maps:get(min_count, Opts, 1), + output => maps:get(output, Opts, undefined) + }; +parse_audit_args([], Opts) -> + case Opts of + #{target := _} -> + Opts; + #{} -> + {error, + "No target specified. Use --github, --hex, --dir, or --multi"} + end; +parse_audit_args(["--github", Url | Rest], Opts) -> + Target = {github_url, Url}, + parse_audit_args(Rest, Opts#{target => Target}); +parse_audit_args(["--hex", Pkg | Rest], #{version := Ver} = Opts) -> + NewOpts = maps:remove(version, Opts), + parse_audit_args(Rest, NewOpts#{target => {hex, Pkg, Ver}}); +parse_audit_args(["--hex", Pkg | Rest], Opts) -> + parse_audit_args(Rest, Opts#{target => {hex, Pkg}}); +parse_audit_args(["--version", Ver | Rest], Opts) -> + case Opts of + #{target := {hex, Name}} -> + parse_audit_args(Rest, Opts#{target => {hex, Name, Ver}}); + #{} -> + parse_audit_args(Rest, Opts#{version => Ver}) + end; +parse_audit_args(["--dir", Dir | Rest], Opts) -> + Target = {local_dir, Dir}, + parse_audit_args(Rest, Opts#{target => Target}); +parse_audit_args(["--multi", File | Rest], Opts) -> + parse_audit_args(Rest, Opts#{multi_file => File}); +parse_audit_args(["-o", File | Rest], Opts) -> + parse_audit_args(Rest, Opts#{output => File}); +parse_audit_args(["--output", File | Rest], Opts) -> + parse_audit_args(Rest, Opts#{output => File}); +parse_audit_args(["--cache", Dir | Rest], Opts) -> + parse_audit_args(Rest, Opts#{cache_dir => Dir}); +parse_audit_args(["-c", Dir | Rest], Opts) -> + parse_audit_args(Rest, Opts#{cache_dir => Dir}); +parse_audit_args(["--top", N | Rest], Opts) -> + case string:to_integer(N) of + {V, []} when V > 0 -> parse_audit_args(Rest, Opts#{top => V}); + _ -> {error, "Invalid --top value: " ++ N} + end; +parse_audit_args(["--min-count", N | Rest], Opts) -> + case string:to_integer(N) of + {V, []} when V > 0 -> + parse_audit_args(Rest, Opts#{min_count => V}); + _ -> + {error, "Invalid --min-count value: " ++ N} + end; +parse_audit_args([Unknown | _], _Opts) -> + {error, "Unknown option: " ++ Unknown}. + +-spec default_eccopts() -> opts_map(). +default_eccopts() -> + #{ + workers => 4, + github => true, + hex => true, + limit => infinity, + resume => false + }. + +-spec parse_ecosystem_args([string()], opts_map()) -> + parse_arg_result() | {error, Reason :: term()}. +parse_ecosystem_args([], Opts) -> + Opts; +parse_ecosystem_args(["--workers", N | Rest], Opts) -> + case string:to_integer(N) of + {V, []} when V > 0 -> + parse_ecosystem_args(Rest, Opts#{workers => V}); + _ -> + {error, "Invalid --workers value: " ++ N} + end; +parse_ecosystem_args(["--github-only" | Rest], Opts) -> + parse_ecosystem_args(Rest, Opts#{hex => false}); +parse_ecosystem_args(["--hex-only" | Rest], Opts) -> + parse_ecosystem_args(Rest, Opts#{github => false}); +parse_ecosystem_args(["--limit", N | Rest], Opts) -> + case string:to_integer(N) of + {V, []} when V > 0 -> + parse_ecosystem_args(Rest, Opts#{limit => V}); + _ -> + {error, "Invalid --limit value: " ++ N} + end; +parse_ecosystem_args(["--stars", N | Rest], Opts) -> + case string:to_integer(N) of + {V, []} when V > 0 -> + parse_ecosystem_args(Rest, Opts#{stars => V}); + _ -> + {error, "Invalid --stars value: " ++ N} + end; +parse_ecosystem_args(["--resume" | Rest], Opts) -> + parse_ecosystem_args(Rest, Opts#{resume => true}); +parse_ecosystem_args(["--cache-dir", Dir | Rest], Opts) -> + parse_ecosystem_args(Rest, Opts#{cache_dir => Dir}); +parse_ecosystem_args([Unknown | _], _Opts) -> + {error, "Unknown option: " ++ Unknown}. + +-spec parse_supported_args([string()], opts_map()) -> + parse_arg_result() | {error, Reason :: term()}. +parse_supported_args([], Opts) -> + Opts; +parse_supported_args(["--module", Mod | Rest], Opts) -> + parse_supported_args(Rest, Opts#{ + module => spectrometer_utils:atom_from_string(Mod) + }); +parse_supported_args(["-m", Mod | Rest], Opts) -> + parse_supported_args(Rest, Opts#{ + module => spectrometer_utils:atom_from_string(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([Unknown | _], _) -> + Reason = io_lib:format("unknown option ~s", [Unknown]), + {error, Reason}. + +-spec parse_filter_args([string()], opts_map()) -> parse_arg_result(). +parse_filter_args([], Opts) -> + Opts#{min_repos => maps:get(min_repos, Opts, 1)}; +parse_filter_args(["--cache", Dir | Rest], Opts) -> + parse_filter_args(Rest, Opts#{cache_dir => Dir}); +parse_filter_args(["-c", Dir | Rest], Opts) -> + parse_filter_args(Rest, Opts#{cache_dir => Dir}); +parse_filter_args(["--min-repos", N | Rest], Opts) -> + case string:to_integer(N) of + {V, []} when V > 0 -> + parse_filter_args(Rest, Opts#{min_repos => V}); + _ -> + {error, "Invalid --min-repos value: " ++ N} + end; +parse_filter_args(["--avm" | Rest], Opts) -> + parse_filter_args(Rest, Opts#{avm => true}); +parse_filter_args(["--csv", File | Rest], Opts) -> + parse_filter_args(Rest, Opts#{csv_file => File}); +parse_filter_args([MaybeFile | Rest], Opts) -> + case MaybeFile of + "--" ++ _ -> + {error, "unknown option " ++ MaybeFile}; + "-" ++ _ -> + {error, "unknown option " ++ MaybeFile}; + _ -> + case maps:is_key(csv_file, Opts) of + false -> + parse_filter_args(Rest, Opts#{csv_file => MaybeFile}); + true -> + {error, "unsupported option " ++ MaybeFile} + end + end. + +-spec parse_query_args([string()], opts_map()) -> parse_arg_result(). +parse_query_args([], #{query := _Q} = Opts) -> + Opts; +parse_query_args([], _) -> + {error, "No function specified. Usage: query 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) -> + parse_query_args(Rest, Opts#{cache_dir => Dir}); +parse_query_args([Query | Rest], Opts) -> + case maps:is_key(query, Opts) of + false -> parse_query_args(Rest, Opts#{query => Query}); + true -> {error, "Multiple queries specified"} + end. + +-spec parse_update_args([string()], opts_map()) -> parse_arg_result(). +parse_update_args([], Opts) -> + Opts#{ + branch => maps:get(branch, Opts, "main"), + tests => maps:get(tests, Opts, true), + cache_dir => maps:get( + cache_dir, + Opts, + spectrometer_utils:user_cache_path() + ) + }; +parse_update_args(["--atomvm-dir", Dir | Rest], Opts) -> + parse_update_args(Rest, Opts#{atomvm_dir => Dir}); +parse_update_args(["--branch", Branch | Rest], Opts) -> + parse_update_args(Rest, Opts#{branch => Branch}); +parse_update_args(["--tag", Tag | Rest], Opts) -> + parse_update_args(Rest, Opts#{tag => Tag}); +parse_update_args(["--output", File | Rest], Opts) -> + parse_update_args(Rest, Opts#{output => File}); +parse_update_args(["--cache", Dir | Rest], Opts) -> + parse_update_args(Rest, Opts#{cache_dir => Dir}); +parse_update_args(["-c", Dir | Rest], Opts) -> + parse_update_args(Rest, Opts#{cache_dir => Dir}); +parse_update_args(["--no-tests" | Rest], Opts) -> + parse_update_args(Rest, Opts#{tests => false}); +parse_update_args(["--force" | Rest], Opts) -> + parse_update_args(Rest, Opts#{force => true}); +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 + ok -> + Runner(Opts); + {error, Reason} -> + 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.app.src b/src/spectrometer.app.src new file mode 100644 index 0000000..540e5ba --- /dev/null +++ b/src/spectrometer.app.src @@ -0,0 +1,40 @@ +%% +%% 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 + +{application, spectrometer, [ + {description, + "Scan Erlang/OTP function usage for AtomVM portability audit"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [ + kernel, + stdlib, + compiler, + syntax_tools, + inets, + ssl + ]}, + {env, []}, + {modules, [ + atomvm_spectrometer, + spectrometer_analyzer, + spectrometer_atomvm, + spectrometer_help, + spectrometer_http, + spectrometer_otp, + spectrometer_reporter, + spectrometer_scanner, + spectrometer_updater, + spectrometer_utils, + spectrometer + ]}, + {priv, "priv"}, + {licenses, ["Apache-2.0"]}, + {links, []} +]}. diff --git a/src/spectrometer.erl b/src/spectrometer.erl new file mode 100644 index 0000000..9d12a97 --- /dev/null +++ b/src/spectrometer.erl @@ -0,0 +1,28 @@ +%% +%% 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). + +-moduledoc """ +Main entry point for the atomvm_spectrometer escript. + +This module is the primary user-facing interface that orchestrates all CLI +commands. It handles argument parsing, command dispatch, and coordination +of scan, ecosystem, supported, filter, update, and query operations. +""". + +-export([main/1]). + +-ifdef(TEST). +-spec main([string()]) -> ok | {error, {halt, non_neg_integer()}}. +-else. +-spec main([string()]) -> no_return(). +-endif. +main(Args) -> + atomvm_spectrometer:main(Args). diff --git a/src/spectrometer_analyzer.erl b/src/spectrometer_analyzer.erl new file mode 100644 index 0000000..5b0142d --- /dev/null +++ b/src/spectrometer_analyzer.erl @@ -0,0 +1,631 @@ +%% +%% Copyright (c) 2026 Winford (UncleGrumpy) +%% All rights reserved. +%% +%% This is part of atomvm_spectrometer +%% +%% Filter command from GitHub Gist: @pguyot/beam_stats.escript#beam_stats_filter.escript +%% Copyright 2026 Paul Guyot +%% https://gist.github.com/pguyot/da327972f1ecdb7041c97addd4e76bb5#file-beam_stats_filter-escript +%% +%% SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) +%% SPDX-FileCopyrightText: 2026 Paul Guyot +%% SPDX-License-Identifier: Apache-2.0 + +-module(spectrometer_analyzer). + +-include("ecosystem.hrl"). + +-moduledoc """ +This modules provides filter and analysis functions. + +This module serves as the abstraction layer between CLI commands and the raw +scanner. It accepts a target (GitHub URL, Hex package or local directory), +orchestrates cloning and downloading, and delegates to the scanner and +reporter. +""". + +-export([ + audit/1, + examine/1, + filter/1 +]). + +-type scan_target() :: + {github_url, string()} + | {github_clone, string()} + | {hex, string()} + | {hex, string(), string()} + | {local_dir, string()}. + +-type stats_map() :: #{{atom(), atom(), arity()} => non_neg_integer()}. + +-type csv_row() :: { + string(), string(), non_neg_integer(), non_neg_integer(), non_neg_integer() +}. + +-doc """ +Audit a target for use with AtomVM using the provided options. + +Options are a map that may include: +- `target` (required if `multi_file` not provided): A single target to scan, +see below for supported formats. +- `multi_file` (required if `target` not provided): A file path containing +multiple targets to scan, one per line. Lines starting with `#` are treated as +comments and ignored. Each line should be either a GitHub URL, a local +directory path, or a Hex package name prefixed with `hex:`. +- `cache_dir`: Optional directory path for caching downloads and clones. +Defaults to a standard user cache directory if not provided. +- `output`: Optional file path to write a CSV report of the scan results. If +not provided, results are only printed to the console. +- `min_count`: Optional minimum call count to include in the report. +Defaults to 1. +- `top`: Optional number of top results to display in the console report. +Defaults to 50. + +Supported target types: + +- `{github_url, Url}` — A GitHub repo URL; examples: owner/repo, github.com/owner/repo, +https://github.com/owner/repo, or https://github.com/owner/repo.git etc... +- `{github_clone, CloneUrl}` — A git clone URL; example: github.com/owner/repo.git +- `{hex, PackageName}` — Latest version from Hex.pm +- `{hex, PackageName, Version}` — Specific version from Hex.pm +- `{local_dir, Dir}` — A local directory path + +Creates temporary directories for clones/downloads and cleans them up +after scanning. +""". +-spec audit(Opts :: map()) -> ok | {error, Reason :: term()}. +audit(Opts) -> + analyze(Opts, true). + +-doc """ +Examine the modules and functions provided by an application or library. + +Options are a map that may include: +- `target` (required if `multi_file` not provided): A single target to scan, +see below for supported formats. +- `multi_file` (required if `target` not provided): A file path containing +multiple targets to scan, one per line. Lines starting with `#` are treated as +comments and ignored. Each line should be either a GitHub URL, a local +directory path, or a Hex package name prefixed with `hex:`. +- `cache_dir`: Optional directory path for caching downloads and clones. +Defaults to a standard user cache directory if not provided. +- `output`: Optional file path to write a CSV report of the scan results. If +not provided, results are only printed to the console. +- `min_count`: Optional minimum call count to include in the report. +Defaults to 1. +- `top`: Optional number of top results to display in the console report. +Defaults to 50. + +Supported target types: + +- `{github_url, Url}` — A GitHub repo URL; examples: owner/repo, github.com/owner/repo, +https://github.com/owner/repo, or https://github.com/owner/repo.git etc... +- `{github_clone, CloneUrl}` — A git clone URL; example: github.com/owner/repo.git +- `{hex, PackageName}` — Latest version from Hex.pm +- `{hex, PackageName, Version}` — Specific version from Hex.pm +- `{local_dir, Dir}` — A local directory path + +Creates temporary directories for clones/downloads and cleans them up +after scanning. +""". +-spec examine(Opts :: map()) -> ok | {error, Reason :: term()}. +examine(Opts) -> + analyze(Opts, false). + +-spec analyze(Opts :: map(), AvmAudit :: boolean()) -> + ok | {error, Reason :: term()}. +analyze(Opts, AvmAudit) -> + try + case spectrometer_utils:start_applications() of + {error, {already_started, _}} -> + ok; + {error, Reason0} -> + io:format( + "Failed to start required applications: ~p\n", + [Reason0] + ), + error(Reason0); + ok -> + ok + end, + case Opts of + #{cache_dir := CacheDir} -> + application:set_env(spectrometer, cache_dir, CacheDir); + #{} -> + ok + end, + + Stats = + case maps:find(multi_file, Opts) of + {ok, File} -> + scan_multi(File); + error -> + #{target := Target} = Opts, + scan_target(Target) + end, + + io:format("\nAnalyzing ~p unique function calls...\n", [ + maps:size(Stats) + ]), + + Report = spectrometer_reporter:generate_report( + Stats, maps:get(min_count, Opts, 1) + ), + + Top = + case maps:get(top, Opts, 50) of + N when is_integer(N), N > 0 -> N; + _ -> 50 + end, + spectrometer_reporter:print_summary(Report, Top, AvmAudit), + + case Opts of + #{output := OutputFile} when is_list(OutputFile) -> + case spectrometer_reporter:write_csv(OutputFile, Report) of + ok -> + ok; + {error, Reason1} -> + io:format( + "Failed to write CSV report to ~s: ~p\n", + [OutputFile, Reason1] + ), + error(Reason1) + end; + #{} -> + ok + end + catch + error:Reason -> {error, Reason} + end. + +-spec scan_target(scan_target()) -> stats_map(). +scan_target({local_dir, Dir}) -> + io:format(" Scanning local directory: ~s\n", [Dir]), + spectrometer_scanner:scan_directory(Dir); +scan_target({github_clone, CloneUrl}) -> + TmpDir = spectrometer_utils:make_temp_dir("gh_"), + try + io:format(" Cloning ~s...\n", [CloneUrl]), + Url = spectrometer_utils:normalize_github_url(CloneUrl), + case spectrometer_http:download_github_repo(Url, TmpDir) of + ok -> + io:format(" Scanning...\n"), + spectrometer_scanner:scan_directory(TmpDir); + {error, Reason} -> + io:format(" Clone failed: ~p\n", [Reason]), + #{} + end + after + spectrometer_utils:purge_dir(TmpDir) + end; +scan_target({github_url, Url}) -> + CloneUrl = spectrometer_utils:normalize_github_url(Url), + scan_target({github_clone, CloneUrl}); +scan_target({hex, PackageName}) -> + scan_target({hex, PackageName, "latest"}); +scan_target({hex, PackageName, "latest"}) -> + %% Fetch package info from Hex to get latest version + Url = lists:flatten( + io_lib:format("https://hex.pm/api/packages/~s", [PackageName]) + ), + case spectrometer_http:fetch(Url) of + {ok, Body} -> + try + case json:decode(Body) of + #{<<"releases">> := [#{<<"version">> := V} | _]} when + is_binary(V) + -> + scan_target({hex, PackageName, binary_to_list(V)}); + _ -> + io:format(" Failed to get version info for ~s\n", [ + PackageName + ]), + #{} + end + catch + _:_ -> + #{} + end; + {error, Reason} -> + io:format(" Failed to fetch ~s from Hex: ~p\n", [ + PackageName, Reason + ]), + #{} + end; +scan_target({hex, PackageName, Version}) -> + io:format(" Downloading ~s-~s from Hex...\n", [PackageName, Version]), + case spectrometer_http:download_hex_tarball(PackageName, Version) of + {ok, TmpDir} -> + try + io:format(" Scanning...\n"), + spectrometer_scanner:scan_directory(TmpDir) + after + spectrometer_utils:purge_dir(TmpDir) + end; + {error, Reason} -> + io:format(" Failed to download ~s-~s: ~p\n", [ + PackageName, Version, Reason + ]), + #{} + end. + +-spec scan_multi(string()) -> + #{{atom(), atom(), arity()} => non_neg_integer()}. +scan_multi(File) -> + case file:read_file(File) of + {ok, Bin} -> + Lines = string:split(binary_to_list(Bin), "\n", all), + Targets = parse_target_lines(Lines), + io:format("Scanning ~p targets from ~s...\n\n", [ + length(Targets), File + ]), + {_, FinalAcc} = lists:foldl( + fun(Target, {Count, Acc}) -> + NewCount = Count + 1, + io:format("[~p/~p]\n", [NewCount, length(Targets)]), + Stats0 = scan_target(Target), + NewAcc = merge_stats(Stats0, Acc), + {NewCount, NewAcc} + end, + {0, #{}}, + Targets + ), + FinalAcc; + {error, Reason} -> + erlang:error({could_not_read_multi_target_file, Reason}) + end. + +-doc false. +% Parse multi-target file lines into scan targets. +% Lines starting with `#` are treated as comments and blank lines are +% skipped. Lines prefixed with `hex:` become Hex targets; GitHub URLs and +% local directory paths are auto-detected. +-spec parse_target_lines([unicode:chardata()]) -> [scan_target()]. +parse_target_lines(Lines) -> + lists:filtermap( + fun(Line) -> + case string:trim(Line) of + "" -> + false; + "#" ++ _ -> + false; + "hex:" ++ Pkg -> + {true, {hex, Pkg}}; + Url -> + case string:find(Url, "github.com") of + nomatch -> + case filelib:is_dir(Url) of + true -> + {true, {local_dir, Url}}; + false -> + case is_valid_url(Url) of + true -> {true, {github_url, Url}}; + false -> false + end + end; + _ -> + {true, {github_url, Url}} + end + end + end, + Lines + ). + +-doc """ +Combine multiple scan results into a single statistics map. + +Adds call counts from `New` into `Acc`, summing counts for keys that +exist in both maps. Useful for merging results from multiple targets +scanned in a multi-target file or ecosystem scan. + +#### Example + +```erlang +1> merge_stats( +1> #{{lists,map,2} => 5}, +1> #{{lists,map,2} => 3, {io,format,2} => 10}). +#{{io,format,2} => 10, {lists,map,2} => 8} +``` +""". +-spec merge_stats(stats_map(), stats_map()) -> stats_map(). +merge_stats(New, Acc) -> + maps:fold( + fun(Key, Count, A) -> + maps:update_with(Key, fun(V) -> V + Count end, Count, A) + end, + Acc, + New + ). + +-spec load_ecosystem_state() -> + #{{atom(), atom(), arity()} => {non_neg_integer(), non_neg_integer()}}. +load_ecosystem_state() -> + CacheDir = spectrometer_utils:user_cache_path(), + StateFile = filename:join(CacheDir, ?ECOSYSTEM_STATE), + case file:read_file(StateFile) of + {ok, Bin} -> + try + case binary_to_term(Bin) of + {spectrometer_v1, _, Stats, _} when is_map(Stats) -> + io:format("Loaded ecosystem state from ~s\n", [ + StateFile + ]), + Stats; + _ -> + io:format( + standard_error, + "Warning: Invalid ecosystem state file: ~s, starting with empty data set.\n", + [ + StateFile + ] + ), + #{} + end + catch + _:_:_ -> + io:format( + standard_error, + "Warning: Unable to load data from ~s, starting with empty data set.\n", + [StateFile] + ), + #{} + end; + {error, enoent} -> + #{}; + {error, Reason} -> + io:format(standard_error, "Error: Could not read ~s: ~p\n", [ + StateFile, Reason + ]), + #{} + end. + +-doc """ +Execute the filter command to analyze ecosystem scan results. + +This function loads data from either a CSV file or the saved ecosystem state, +filters the results based on repository count and optional AtomVM support status, +and prints a formatted report. +""". +-spec filter(atomvm_spectrometer:opts_map()) -> ok | {error, term()}. +filter(Opts) -> + MinRepos = maps:get(min_repos, Opts, 1), + AvmFilter = maps:get(avm, Opts, false), + case Opts of + #{cache_dir := CacheDir} -> + application:set_env(spectrometer, cache_dir, CacheDir), + spectrometer_atomvm:reload_db(); + #{} -> + ok + end, + + case load_filter_data(Opts) of + {error, _} = Error -> + Error; + Rows -> + case filter_by_repositories(Rows, MinRepos) of + [] -> + io:format( + standard_error, + "Error: No OTP functions found with >= ~p repos. Try lowering --min-repos?\n", + [MinRepos] + ), + ok; + FilteredByRepos -> + Filtered = + case AvmFilter of + true -> filter_by_avm_support(FilteredByRepos); + false -> FilteredByRepos + end, + + case Filtered of + [] -> + io:format( + standard_error, + "No functions match the specified criteria.\n", + [] + ), + ok; + _ -> + print_filtered_results( + Filtered, MinRepos, AvmFilter + ) + end + end + end. + +-doc """ +Load filter data from either a CSV file or the ecosystem state. +""". +-spec load_filter_data(atomvm_spectrometer:opts_map()) -> + [csv_row()] | {error, string()}. +load_filter_data(Opts) -> + case maps:find(csv_file, Opts) of + {ok, CsvFile} -> + case file:read_file(CsvFile) of + {ok, Bin} -> + [_Header | DataLines] = string:split( + binary_to_list(Bin), "\n", all + ), + parse_csv_rows(DataLines); + {error, Reason} -> + {error, + "Could not read CSV file: " ++ + file:format_error(Reason)} + end; + error -> + case load_ecosystem_state() of + Stats when map_size(Stats) > 0 -> + maps:fold( + fun({Mod, Fun, Arity}, {Calls, RepoCount}, Acc) -> + [ + { + atom_to_list(Mod), + atom_to_list(Fun), + Arity, + Calls, + RepoCount + } + | Acc + ] + end, + [], + Stats + ); + _ -> + {error, + "No ecosystem state file found. Run 'ecosystem' command first."} + end + end. + +-doc """ +Filter rows by minimum repository count and OTP module status. +""". +-spec filter_by_repositories([csv_row()], non_neg_integer()) -> [csv_row()]. +filter_by_repositories(Rows, MinRepos) -> + lists:filter( + fun({Mod, _Fun, _Arity, _Calls, RepoCount}) -> + RepoCount >= MinRepos andalso spectrometer_otp:is_otp_module(Mod) + end, + Rows + ). + +-doc """ +Filter rows by AtomVM support status, only report unsupported functions. +""". +-spec filter_by_avm_support([csv_row()]) -> [csv_row()]. +filter_by_avm_support(Rows) -> + lists:filter( + fun({ModStr, FunStr, Arity, _Calls, _RepoCount}) -> + % First try to create atoms using list_to_existing_atom for validation + {Mod, Fun} = { + spectrometer_utils:atom_from_string(ModStr), + spectrometer_utils:atom_from_string(FunStr) + }, + false =:= spectrometer_atomvm:is_supported({Mod, Fun, Arity}) + end, + Rows + ). + +%% @private +%% Check if a string looks like a valid URL or repo path. +is_valid_url(Url) -> + case + string:find(Url, "http://") =:= nomatch andalso + string:find(Url, "https://") =:= nomatch andalso + string:find(Url, "git@") =:= nomatch andalso + string:find(Url, "/") =:= nomatch + of + true -> + %% No protocol, no ssh, no slash — could be a hex pkg or garbage + false; + false -> + true + end. + +-doc """ +Print the filtered results organized by module. +""". +-spec print_filtered_results([csv_row()], non_neg_integer(), boolean()) -> ok. +print_filtered_results(Filtered, MinRepos, AvmFilter) -> + ByModule = lists:foldl( + fun({Mod, Fun, Arity, Calls, RC}, Acc) -> + maps:update_with( + Mod, + fun(L) -> [{Fun, Arity, Calls, RC} | L] end, + [{Fun, Arity, Calls, RC}], + Acc + ) + end, + #{}, + Filtered + ), + + Modules = lists:sort(maps:to_list(ByModule)), + TotalFuns = lists:sum([length(Funs) || {_, Funs} <- Modules]), + + case AvmFilter of + true -> + io:format( + "OTP functions not supported by AtomVM (>= ~p repos): ~p functions across ~p modules\n\n", + [MinRepos, TotalFuns, length(Modules)] + ); + false -> + io:format( + "OTP functions used by >= ~p repos: ~p functions across ~p modules\n\n", + [MinRepos, TotalFuns, length(Modules)] + ) + end, + + lists:foreach( + fun({Mod, Funs}) -> + Sorted = lists:sort( + fun({_, _, _, RC1}, {_, _, _, RC2}) -> RC1 > RC2 end, Funs + ), + io:format("~ts (~p functions):\n", [Mod, length(Sorted)]), + lists:foreach( + fun({Fun, Arity, Calls, RC}) -> + io:format(" ~ts/~p (~p calls in ~p repos)\n", [ + Fun, Arity, Calls, RC + ]) + end, + Sorted + ), + io:format("\n") + end, + Modules + ). + +-doc """ +Parse CSV data lines into row tuples. + +Supports 4-column (`module,function,arity,calls`) and 5-column +(`module,function,arity,calls,repo_count`) formats. +""". +-spec parse_csv_rows([string()]) -> [csv_row()]. +parse_csv_rows(Lines) -> + parse_csv_rows(Lines, []). + +-spec parse_csv_rows([string()], Acc :: list()) -> [csv_row()]. +parse_csv_rows([], Acc) -> + lists:reverse(Acc); +parse_csv_rows([Line | Lines], Acc) -> + case string:trim(Line) of + "" -> + parse_csv_rows(Lines, Acc); + Trimmed -> + case string:split(Trimmed, ",", all) of + [ModStr, FunStr, ArityStr, CallsStr, RCStr] -> + case + { + string:to_integer(string:trim(ArityStr)), + string:to_integer(string:trim(CallsStr)), + string:to_integer(string:trim(RCStr)) + } + of + {{Arity, []}, {Calls, []}, {RC, []}} -> + parse_csv_rows(Lines, [ + {ModStr, FunStr, Arity, Calls, RC} | Acc + ]); + _ -> + parse_csv_rows(Lines, Acc) + end; + [ModStr, FunStr, ArityStr, CallsStr] -> + case + { + string:to_integer(string:trim(ArityStr)), + string:to_integer(string:trim(CallsStr)) + } + of + {{Arity, []}, {Calls, []}} -> + parse_csv_rows(Lines, [ + {ModStr, FunStr, Arity, Calls, 1} | Acc + ]); + _ -> + parse_csv_rows(Lines, Acc) + end; + _ -> + parse_csv_rows(Lines, Acc) + end + end. diff --git a/src/spectrometer_atomvm.erl b/src/spectrometer_atomvm.erl new file mode 100644 index 0000000..90bc861 --- /dev/null +++ b/src/spectrometer_atomvm.erl @@ -0,0 +1,521 @@ +%% +%% 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_atomvm). + +-include_lib("kernel/include/file.hrl"). + +-moduledoc """ +Queries AtomVM supported functions database. + +This module is the source of truth for AtomVM compatibility data. It loads the +supported functions database from a bundled `supported_functions.data` file or +a user override, and provides functions for checking whether a specific OTP +function is supported by AtomVM, along with platform and version information. + +### Data Source + +The bundled database is at `priv/supported_functions.data` — a human-readable +Erlang term list loadable with `file:consult/1`. The format is: + +```erlang +[{module(), [{function(), arity(), platforms(), since()}]}] +``` + +Where `platforms` is `all` or a list of platform atoms +(`esp32`, `stm32`, `rp2`, `emscripten`, or `generic_unix`), and `since` is a +binary version string or `{unreleased, Branch :: binary()}`. + +### User Override + +Place a custom `supported_functions.data` in your cache directory to +completely replace the bundled database: + +- **Linux:** `~/.cache/spectrometer/supported_functions.data` +- **macOS:** `~/Library/Caches/spectrometer/supported_functions.data` +- **Windows:** `%APPDATA%/spectrometer/supported_functions.data` + +The override file uses the same human-readable format as the bundled file. The +user cache override may also be updated using +`spectrometer_updater:update_datafile/2`. +""". + +-export([ + get_unsupported/1, + is_supported/1, + load_db/0, + query/1, + reload_db/0, + report_supported/1 +]). + +-doc """ +List all modules supported by AtomVM. + +Returns a list of module atoms that appear in the supported functions database. +""". +-spec supported_modules() -> [atom()]. +supported_modules() -> + maps:keys(load_db()). + +-doc """ +Check if a function is supported and return platforms and version information. + +Returns `{true, Platforms, Since}` if the function is supported, or `false` +otherwise. `Platforms` is the atom `all` or a list of platform atoms. +`Since` is a binary version string (e.g. `<<"v0.5.0">>`) or +`{unreleased, Branch :: binary()}` for functions not yet in a release. +""". +-spec support_info({atom(), atom(), non_neg_integer()}) -> + {true, [atom()] | all, binary() | {unreleased, binary()}} | false. +support_info({Mod, Fun, Arity}) -> + DB = load_db(), + case DB of + #{Mod := Funs} -> + FunMatches = [E || E <- Funs, element(1, E) =:= Fun], + case find_arity(FunMatches, Arity) of + none -> false; + {Platforms, Since} -> {true, Platforms, Since} + end; + _ -> + false + end. + +-doc """ +Check if a function is supported + +Returns `boolean()`. +""". +-spec is_supported({atom(), atom(), non_neg_integer()}) -> boolean(). +is_supported({Mod, Fun, Arity}) -> + DB = load_db(), + case DB of + #{Mod := Funs} -> + FunMatches = [E || E <- Funs, element(1, E) =:= Fun], + case find_arity(FunMatches, Arity) of + none -> false; + {_, _} -> true + end; + _ -> + false + end. + +-doc false. +%% Find matching arity in function entries and return platforms and since info. +find_arity(FunMatches, Arity) -> + find_arity(FunMatches, Arity, none). + +-doc false. +find_arity([], _Arity, Acc) -> + Acc; +find_arity( + [{_, all, Platforms, Since} | _Rest], _Arity, _Acc +) -> + {Platforms, Since}; +find_arity( + [{_, A, Platforms, Since} | Rest], Arity, _Acc +) when is_integer(A) -> + case A =:= Arity of + true -> {Platforms, Since}; + false -> find_arity(Rest, Arity, none) + end; +find_arity( + [{_, ArityList, Platforms, Since} | Rest], Arity, _Acc +) when is_list(ArityList) -> + case lists:member(Arity, ArityList) of + true -> {Platforms, Since}; + false -> find_arity(Rest, Arity, none) + end; +find_arity([_ | Rest], Arity, Acc) -> + %% Skip entries with unexpected format + find_arity(Rest, Arity, Acc). + +-doc """ +Return all supported functions with platform and version information. + +Returns a list of `{Module, Function, Arity, Platforms, Since}` tuples +for every function in the database. +""". +-spec get_supported_functions() -> + [ + { + atom(), + atom(), + non_neg_integer() | all | [non_neg_integer()], + [atom()] | all, + binary() | {unreleased, binary()} + } + ]. +get_supported_functions() -> + DB = load_db(), + lists:flatten([ + {M, F, A, Platforms, Since} + || {M, Funs} <- maps:to_list(DB), {F, A, Platforms, Since} <- Funs + ]). + +-doc """ +Filter scan statistics to return unsupported functions only. + +Given a statistics map from a scan, returns a list of +`{{Module, Function, Arity}, Count}` tuples for all functions that are +not supported by AtomVM, sorted by call count descending. +""". +-spec get_unsupported(#{ + {atom(), atom(), non_neg_integer()} => non_neg_integer() +}) -> + [{{atom(), atom(), non_neg_integer()}, non_neg_integer()}]. +get_unsupported(Stats) -> + Unsupported = maps:filter( + fun(Key, _Count) -> + not is_supported(Key) + end, + Stats + ), + lists:sort( + fun({_, C1}, {_, C2}) -> C1 > C2 end, + maps:to_list(Unsupported) + ). + +-doc """ +Force reload of the database from disk. + +Clears the cached database stored in the process dictionary. Subsequent +calls to `load_db/0` or `is_supported/1` will re-read the database file. +""". +-spec reload_db() -> ok. +reload_db() -> + erase(supported_db), + ok. + +-doc false. +%% Load database with platform and since information, cached in process dictionary. +load_db() -> + case get(supported_db) of + undefined -> + DB = load_db_internal(), + put(supported_db, DB), + DB; + DB -> + DB + end. + +-doc false. +%% Load the database supporting platform and version information. +%% Checks user override first, then bundled file. +load_db_internal() -> + UserPath = spectrometer_utils:user_db_file(), + BundledPath = spectrometer_utils:bundled_data_path(), + case filelib:is_regular(UserPath) of + true -> + consult_db(UserPath); + false -> + case filelib:is_regular(BundledPath) of + true -> + consult_db(BundledPath); + false -> + io:format( + standard_error, + "Warning: No supported functions database found.\n" + " Expected at: ~s\n" + " Or user file at: ~s\n" + " A minimal database may be created by running `spectrometer update `\n" + " A complete dataset may be generated by running the generate_fun_data.sh in the project root.\n", + [BundledPath, UserPath] + ), + #{} + end + end. + +-doc false. +%% Read a human-readable database file (list of tuples). +-spec consult_db(file:name_all()) -> + #{ + atom() => [ + { + atom(), + arity() | all | [arity()], + [atom()] | all, + binary() | {unreleased, binary()} + } + ] + }. +consult_db(Path) -> + case file:consult(Path) of + {ok, Data} -> + try + maps:from_list(lists:flatten(Data)) + catch + _:Reason -> + io:format( + standard_error, + "Warning: Could not read data: ~p, using empty database\n", + [Reason] + ), + #{} + end; + {error, Reason} -> + io:format( + standard_error, + "Warning: Could not read ~s: ~p, using empty database\n", + [Path, Reason] + ), + #{} + end. + +-doc """ +Display a report of functions unsupported by AtomVM. + +Opts = #{cache_dir => Dir, query => Query} +""". +-spec query(Opts :: atomvm_spectrometer:opts_map()) -> + ok | {error, Reason :: term()}. +query(Opts) -> + case Opts of + #{cache_dir := CacheDir} -> + application:set_env(spectrometer, cache_dir, CacheDir), + reload_db(); + #{} -> + ok + end, + Query = maps:get(query, Opts), + case parse_query_string(Query) of + {ok, Mod, Fun} -> + show_query({Mod, Fun}), + ok; + {ok, Mod, Fun, Arity} -> + show_query({Mod, Fun, Arity}), + ok; + {error, Reason} -> + io:format(standard_error, "Error: ~s\n", [Reason]), + io:format( + standard_error, "Usage: query Module:Function[/Arity]\n", [] + ), + {error, Reason} + end. + +-doc """ +Parse a query string in `Module:Function[/Arity]` format. + +Returns `{ok, Module, Function, Arity}` or `{ok, Module, Function}` +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) -> + 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), + spectrometer_utils:atom_from_string(FunStr), + Arity}; + _ -> + {error, "Invalid arity: " ++ ArityStr} + end; + [FunStr] -> + {ok, spectrometer_utils:atom_from_string(ModStr), + spectrometer_utils:atom_from_string(FunStr)} + end; + _ -> + {error, + "Invalid format. Use Module:Function or Module:Function/Arity"} + end. + +-spec show_query({atom(), atom()} | {atom(), atom(), arity()}) -> ok. +show_query({Mod, Fun}) -> + Supported = get_supported_functions(), + Matches = [ + {A, Platforms, Since} + || {M, F, A, Platforms, Since} <- Supported, + M =:= Mod, + F =:= Fun + ], + case lists:sort(Matches) of + [] -> + io:format("~ts:~ts is NOT supported by AtomVM\n", [Mod, Fun]); + ArityList -> + io:format("~ts:~ts supported arities:\n", [Mod, Fun]), + lists:foreach( + fun({Arity, Platforms, Since}) -> + io:format( + " /~p (~s, since: ~s)\n", + [ + Arity, + format_platforms(Platforms), + format_since(Since) + ] + ) + end, + ArityList + ) + end; +show_query({Mod, Fun, Arity}) -> + case support_info({Mod, Fun, Arity}) of + {true, Platforms, Since} -> + io:format( + "~ts:~ts/~p is SUPPORTED by AtomVM (~s, since: ~s)\n", + [ + Mod, + Fun, + Arity, + format_platforms(Platforms), + format_since(Since) + ] + ); + false -> + io:format( + "~ts:~ts/~p is NOT supported by AtomVM\n", + [Mod, Fun, Arity] + ) + end. + +-doc """ +Format a platform list for display. + +Returns `"all"` for the atom `all`, or a comma-separated +string of platform names. +""". +-spec format_platforms([atom()] | all) -> string(). +format_platforms(all) -> + "all"; +format_platforms(Platforms) when is_list(Platforms) -> + string:join([atom_to_list(P) || P <- Platforms], ", "). + +-doc """ +Format since data for display. + +Formats release branch names to "unreleased {{VERSION}}", binary tags to +`t:string()`. Functions from unrecognized branches or tags will shown as an +"unknown" release, this would happen if users added downstream drivers to their +supported functions data using the `spectrometer update` command. +""". +-spec format_since(binary() | {unreleased, binary()}) -> string(). +format_since(<<"unknown">>) -> + "unknown"; +format_since({unreleased, Branch}) when is_binary(Branch) -> + "unreleased " ++ binary_to_list(Branch); +format_since(Version) when is_binary(Version) -> + binary_to_list(Version). + +-doc false. +-spec report_supported(atomvm_spectrometer:opts_map()) -> + ok | {error, unsupported}. +report_supported(Opts) -> + case Opts of + #{cache_dir := CacheDir} -> + application:set_env(spectrometer, cache_dir, CacheDir), + reload_db(); + #{} -> + ok + end, + case Opts of + #{module := Mod} -> + print_supported(Mod); + #{} -> + print_supported() + end. + +-spec print_supported() -> ok. +print_supported() -> + Mods = supported_modules(), + io:format("AtomVM supported OTP modules (~p total):\n\n", [length(Mods)]), + lists:foreach( + fun print_supported/1, + lists:sort(Mods) + ). + +-spec print_supported(atom()) -> ok | {error, unsupported}. +print_supported(Mod) -> + 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 + end, + lists:sort(Funs) + ), + io:format("\n"); + not_found -> + io:format( + standard_error, + "Module ~ts not found in AtomVM supported database\n", + [atom_to_list(Mod)] + ), + {error, unsupported} + end. + +-spec supported_db_lookup(atom()) -> + {ok, [ + { + atom(), + arity() | all | [arity()], + [atom()] | all, + binary() | {unreleased, binary()} + } + ]} + | not_found. +supported_db_lookup(Mod) -> + Supported = get_supported_functions(), + ModFuns = + [ + {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. diff --git a/src/spectrometer_ecosystem.erl b/src/spectrometer_ecosystem.erl new file mode 100644 index 0000000..d04a314 --- /dev/null +++ b/src/spectrometer_ecosystem.erl @@ -0,0 +1,481 @@ +%% +%% Copyright 2026 Paul Guyot +%% GitHub Gist @pguyot/beam_stats.escript +%% https://gist.github.com/pguyot/da327972f1ecdb7041c97addd4e76bb5 +%% +%% Adapted for atomvm_spectrometer +%% Copyright (c) 2026 Winford (UncleGrumpy) +%% +%% SPDX-FileCopyrightText: 2026 Paul Guyot +%% SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) +%% SPDX-License-Identifier: Apache-2.0 + +-module(spectrometer_ecosystem). + +-include("ecosystem.hrl"). + +-export([run/1]). + +-define(SAVE_INTERVAL, 10). + +-type work_item() :: {github | hex, map()}. +-type coordinator_state() :: #{ + work => [work_item()], + scanned => sets:set(map()), + stats => #{ + {atom(), atom(), arity()} => {non_neg_integer(), non_neg_integer()} + }, + total_processed => non_neg_integer(), + total_work => non_neg_integer(), + since_save => non_neg_integer(), + active_workers => non_neg_integer(), + worker_monitors => #{reference() => pid()}, + parent => pid() +}. + +-doc false. +-spec run(atomvm_spectrometer:opts_map()) -> ok | {error, term()}. +run(Opts) -> + try + case spectrometer_utils:start_applications() of + {error, already_started} -> + ok; + {error, Reason0} -> + io:format( + "Failed to start required applications: ~p\n", + [Reason0] + ), + error(Reason0); + ok -> + ok + end, + + {Scanned, Stats, TotalProcessed} = + case maps:get(resume, Opts) of + true -> load_state(); + false -> {sets:new([{version, 2}]), #{}, 0} + end, + + Limit = maps:get(limit, Opts), + Stars = maps:get(stars, Opts, infinity), + GithubRepos = + case maps:get(github, Opts) of + true -> spectrometer_http:fetch_github_repos({Limit, Stars}); + false -> [] + end, + HexLeft = + case Limit of + infinity -> infinity; + _ -> max(0, Limit - length(GithubRepos)) + end, + HexPackages = + case maps:get(hex, Opts) of + true -> spectrometer_http:fetch_hex_packages(HexLeft); + false -> [] + end, + + {Repos, Packages} = deduplicate(GithubRepos, HexPackages), + + io:format( + "Work items: ~p GitHub repos, ~p Hex packages\n", + [length(Repos), length(Packages)] + ), + + Work0 = [{github, R} || R <- Repos] ++ [{hex, P} || P <- Packages], + Work = lists:filter( + fun({Type, Item}) -> + Key = work_key(Type, Item), + not sets:is_element(Key, Scanned) + end, + Work0 + ), + + io:format( + "Items to scan: ~p (skipping ~p already scanned)\n", + [length(Work), length(Work0) - length(Work)] + ), + + case run_coordinator(Work, Scanned, Stats, TotalProcessed, Opts) of + {ok, _FinalStats} -> ok; + {error, Err} -> {error, Err} + end + catch + Class:Reason:Stack -> + {error, {Class, Reason, Stack}} + end. + +-doc """ +Remove duplicate work items between GitHub and Hex sources. + +Returns `{GithubRepos, FilteredHexPackages}` where Hex packages whose +GitHub URL matches an already-included GitHub repo are removed. +""". +-spec deduplicate([map()], [map()]) -> {[map()], [map()]}. +deduplicate(GithubRepos, HexPackages) -> + GithubUrls = sets:from_list( + [ + spectrometer_utils:normalize_github_url(maps:get(html_url, R)) + || R <- GithubRepos + ], + [ + {version, 2} + ] + ), + FilteredHex = lists:filter( + fun(P) -> + case maps:get(github_url, P) of + "" -> + true; + Url -> + Normalized = spectrometer_utils:normalize_github_url( + Url + ), + not sets:is_element(Normalized, GithubUrls) + end + end, + HexPackages + ), + {GithubRepos, FilteredHex}. + +-doc """ +Generate a unique string key for a work item. +""". +-spec work_key(github | hex, map()) -> string(). +work_key(github, #{full_name := Name}) -> "github:" ++ Name; +work_key(hex, #{name := Name}) -> "hex:" ++ Name. + +-spec run_coordinator( + [work_item()], + sets:set(map()), + #{{atom(), atom(), arity()} => {non_neg_integer(), non_neg_integer()}}, + non_neg_integer(), + atomvm_spectrometer:opts_map() +) -> + {ok, #{{atom(), atom(), arity()} => {non_neg_integer(), non_neg_integer()}}} + | {error, term()}. +run_coordinator(Work, Scanned, Stats, TotalProcessed, Opts) -> + NumWorkers = maps:get(workers, Opts), + case NumWorkers < 1 of + true -> + {error, {invalid_workers, NumWorkers}}; + false -> + do_run_coordinator( + Work, Scanned, Stats, TotalProcessed, Opts, NumWorkers + ) + end. + +do_run_coordinator(Work, Scanned, Stats, TotalProcessed, _Opts, NumWorkers) -> + TotalWork = length(Work) + TotalProcessed, + Self = self(), + {CoordPid, CoordRef} = spawn_monitor(fun() -> + coordinator_loop_initial(#{ + work => Work, + scanned => Scanned, + stats => Stats, + total_processed => TotalProcessed, + total_work => TotalWork, + since_save => 0, + active_workers => NumWorkers, + worker_monitors => #{}, + parent => Self + }) + end), + receive + {coordinator_done, FinalStats} -> {ok, FinalStats}; + {error, Reason} -> {error, Reason}; + {'DOWN', CoordRef, process, CoordPid, Reason} -> {error, Reason} + end. + +-spec coordinator_loop_initial(coordinator_state()) -> no_return(). +coordinator_loop_initial(State) -> + #{active_workers := NumWorkers} = State, + WorkerMonitors = spawn_workers(self(), NumWorkers), + coordinator_loop(State#{worker_monitors => WorkerMonitors}). + +-spec spawn_workers(pid(), non_neg_integer()) -> #{reference() => pid()}. +spawn_workers(_CoordPid, 0) -> + #{}; +spawn_workers(CoordPid, N) when N > 0 -> + {WorkerPid, MonitorRef} = spawn_monitor(fun() -> worker_loop(CoordPid) end), + Rest = spawn_workers(CoordPid, N - 1), + Rest#{MonitorRef => WorkerPid}. + +-spec coordinator_loop(coordinator_state()) -> no_return(). +coordinator_loop(State) -> + receive + {get_work, WorkerPid} -> + case maps:get(work, State) of + [] -> + WorkerPid ! no_more_work, + coordinator_loop(State); + [Item | Rest] -> + WorkerPid ! {work, Item}, + coordinator_loop(State#{work => Rest}) + end; + {result, Key, RepoStats} -> + #{ + scanned := Scanned, + stats := Stats, + total_processed := TP, + total_work := TW, + since_save := SS, + parent := Parent + } = State, + NewScanned = sets:add_element(Key, Scanned), + NewStats = merge_repo_stats(RepoStats, Stats), + NewTP = TP + 1, + NewSS = SS + 1, + io:format( + "\r Progress: ~p/~p (~.1f%) ", + [NewTP, TW, NewTP / max(1, TW) * 100] + ), + case NewSS >= ?SAVE_INTERVAL of + true -> + case save_state(NewScanned, NewStats, NewTP) of + ok -> + coordinator_loop(State#{ + scanned => NewScanned, + stats => NewStats, + total_processed => NewTP, + since_save => 0 + }); + {error, Reason} -> + io:format( + "\n Warning: Failed to save state: ~p\n", + [Reason] + ), + Parent ! {error, {save_state, Reason}} + end; + false -> + coordinator_loop(State#{ + scanned => NewScanned, + stats => NewStats, + total_processed => NewTP, + since_save => NewSS + }) + end; + {worker_done, _WorkerPid} -> + handle_worker_exit(State, undefined); + {'DOWN', MonitorRef, process, WorkerPid, Reason} -> + case maps:get(worker_monitors, State, #{}) of + #{MonitorRef := _} -> + handle_worker_exit(State, {MonitorRef, WorkerPid, Reason}); + #{} -> + % Unknown monitor ref - just clean up + NewMonitors = maps:remove( + MonitorRef, maps:get(worker_monitors, State, #{}) + ), + coordinator_loop(State#{worker_monitors => NewMonitors}) + end + end. + +handle_worker_exit(State, ExitInfo) -> + #{ + active_workers := AW, + stats := Stats, + scanned := Scanned, + total_processed := TP, + parent := Parent, + worker_monitors := Monitors + } = State, + NewAW = AW - 1, + NewMonitors = + case ExitInfo of + undefined -> + Monitors; + {MonitorRef, _WorkerPid, _Reason} -> + maps:remove(MonitorRef, Monitors) + end, + case NewAW of + 0 -> + io:format("\n"), + case save_state(Scanned, Stats, TP) of + ok -> + Parent ! {coordinator_done, Stats}; + {error, Reason} -> + Parent ! {error, {save_state, Reason}} + end; + _ -> + coordinator_loop(State#{ + active_workers => NewAW, + worker_monitors => NewMonitors + }) + end. + +-spec worker_loop(pid()) -> no_return(). +worker_loop(CoordPid) -> + CoordPid ! {get_work, self()}, + receive + {work, {github, Item}} -> + Key = work_key(github, Item), + RepoStats = + try + process_github_repo(Item) + catch + _:Reason -> + io:format("\n Error processing ~s: ~p\n", [Key, Reason]), + #{} + end, + CoordPid ! {result, Key, RepoStats}, + worker_loop(CoordPid); + {work, {hex, Item}} -> + Key = work_key(hex, Item), + RepoStats = + try + process_hex_package(Item) + catch + _:Reason -> + io:format("\n Error processing ~s: ~p\n", [Key, Reason]), + #{} + end, + CoordPid ! {result, Key, RepoStats}, + worker_loop(CoordPid); + no_more_work -> + CoordPid ! {worker_done, self()}, + ok + end. + +-spec process_github_repo(map()) -> + #{{atom(), atom(), arity()} => non_neg_integer()}. +process_github_repo(Repo) -> + CloneUrl = maps:get(clone_url, Repo), + TmpDir = spectrometer_utils:make_temp_dir("gh_"), + try + case + spectrometer_utils:run_git_command( + [ + "clone", "--depth", "1", "--quiet", CloneUrl, TmpDir + ], + [{"GIT_TERMINAL_PROMPT", "0"}] + ) + of + {ok, _} -> + case filelib:is_dir(TmpDir) of + true -> spectrometer_scanner:scan_directory(TmpDir); + false -> #{} + end; + {error, _} -> + #{} + end + after + _ = spectrometer_utils:purge_dir(TmpDir) + end. + +-spec process_hex_package(map()) -> + #{{atom(), atom(), arity()} => non_neg_integer()}. +process_hex_package(Package) -> + Name = maps:get(name, Package), + Version = maps:get(version, Package), + case spectrometer_http:download_hex_tarball(Name, Version) of + {ok, TmpDir} -> + try + spectrometer_scanner:scan_directory(TmpDir) + after + spectrometer_utils:purge_dir(TmpDir) + end; + {error, _Reason} -> + #{} + end. + +-doc """ +Merge a single repo's scan statistics into the global ecosystem accumulator. + +Each entry in `GlobalStats` tracks `{TotalCalls, RepoCount}`. +""". +-spec merge_repo_stats( + #{{atom(), atom(), arity()} => non_neg_integer()}, + #{{atom(), atom(), arity()} => {non_neg_integer(), non_neg_integer()}} +) -> + #{{atom(), atom(), arity()} => {non_neg_integer(), non_neg_integer()}}. +merge_repo_stats(RepoStats, GlobalStats) -> + maps:fold( + fun(Key, CallCount, Acc) -> + maps:update_with( + Key, + fun({TC, RC}) -> {TC + CallCount, RC + 1} end, + {CallCount, 1}, + Acc + ) + end, + GlobalStats, + RepoStats + ). + +-spec save_state( + sets:set(map()), + #{{atom(), atom(), arity()} => {non_neg_integer(), non_neg_integer()}}, + non_neg_integer() +) -> ok | {error, term()}. +save_state(Scanned, Stats, TotalProcessed) -> + State = {spectrometer_v1, Scanned, Stats, TotalProcessed}, + CacheDir = + case application:get_env(spectrometer, cache_dir) of + undefined -> spectrometer_utils:user_cache_path(); + {ok, CacheDir1} -> CacheDir1 + end, + TmpFile = filename:join(CacheDir, ?ECOSYSTEM_STATE ++ ".tmp"), + case filelib:ensure_path(CacheDir) of + ok -> + case + file:write_file(TmpFile, term_to_binary(State, [compressed])) + of + ok -> + EcoState = filename:join(CacheDir, ?ECOSYSTEM_STATE), + case file:rename(TmpFile, EcoState) of + ok -> ok; + {error, Reason} -> {error, {rename, Reason}} + end; + {error, Reason} -> + {error, {write, Reason}} + end; + {error, Reason} -> + {error, {ensure_path, Reason}} + end. + +-spec load_state() -> + { + sets:set(map()), + #{{atom(), atom(), arity()} => {non_neg_integer(), non_neg_integer()}}, + non_neg_integer() + }. +load_state() -> + case + file:read_file( + filename:join( + spectrometer_utils:user_cache_path(), ?ECOSYSTEM_STATE + ) + ) + of + {ok, Bin} -> + try + case binary_to_term(Bin) of + {spectrometer_v1, Scanned, Stats, TotalProcessed} -> + io:format( + "Resumed state: ~p items already scanned\n", [ + TotalProcessed + ] + ), + {Scanned, Stats, TotalProcessed}; + _ -> + io:format( + "Warning: Invalid state file, starting fresh\n" + ), + {sets:new([{version, 2}]), #{}, 0} + end + catch + _:_ -> + io:format( + "Warning: Could not decode state file, starting fresh\n" + ), + {sets:new([{version, 2}]), #{}, 0} + end; + {error, enoent} -> + io:format("No state file found, starting fresh\n"), + {sets:new([{version, 2}]), #{}, 0}; + {error, Reason} -> + io:format( + "Warning: Could not read state file (~p), starting fresh\n", + [Reason] + ), + {sets:new([{version, 2}]), #{}, 0} + end. diff --git a/src/spectrometer_help.erl b/src/spectrometer_help.erl new file mode 100644 index 0000000..748678f --- /dev/null +++ b/src/spectrometer_help.erl @@ -0,0 +1,277 @@ +%% +%% 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_help). + +-export([ + usage/0, + usage/1 +]). + +-type command() :: + audit | ecosystem | examine | supported | filter | update | query. + +-doc "Print general help text listing all commands.". +-spec usage() -> ok. +usage() -> + io:format( + "\nspectrometer ~s\n" + "Usage: spectrometer [OPTIONS] COMMAND [COMMAND_OPTIONS]\n" + "\n" + "Options:\n" + " -h, --help Display this help message\n" + " --version Display version number\n" + "\n" + "Commands:\n" + " help Show this help message\n" + " audit Audit a single target (GitHub repo, Hex package, or directory)\n" + " ecosystem Scan top GitHub repos and/or Hex packages\n" + " examine Examine modules and functions provided by an application\n" + " supported List all AtomVM-supported OTP functions\n" + " filter Filter ecosystem audit CSV output by OTP module\n" + " update Regenerate supported functions database from AtomVM sources\n" + " query Query AtomVM function support by Module:Function[/Arity]\n" + " version Display version number and exit\n" + "\n" + "Get detailed help on a command:\n" + " spectrometer help audit\n" + " spectrometer help ecosystem\n" + " spectrometer help examine\n" + " spectrometer help supported\n" + " spectrometer help filter\n" + " spectrometer help update\n" + " spectrometer help query\n", + [spectrometer_utils:version()] + ). + +-doc "Print help text for the given command.". +-spec usage(command() | term()) -> ok. +usage(Command) -> + case Command of + audit -> + usage_audit(); + ecosystem -> + usage_ecosystem(); + examine -> + usage_examine(); + supported -> + usage_supported(); + filter -> + usage_filter(); + update -> + usage_update(); + query -> + usage_query(); + _ -> + io:format("Unsupported command: ~p\n", [Command]), + usage() + end. + +%% Print help text for the 'audit' command. +-spec usage_audit() -> ok. +usage_audit() -> + io:format( + "Usage: spectrometer audit [TARGET] [OPTIONS]\n" + "\n" + "Audit a single target, or a list of targets from a file for OTP function usage and\n" + "report which functions are NOT supported by AtomVM.\n" + "\n" + "Target (exactly one):\n" + " --github GitHub repository URL (e.g. https://github.com/ninenines/cowboy)\n" + " --hex Hex package name (optionally with --version)\n" + " --dir Local directory containing .erl source files\n" + " --multi File with one target per line (see format below)\n" + "\n" + "Options:\n" + " -o Write full CSV report to file\n" + " --output Same as -o\n" + " -c Use alternate cache directory for supported functions DB\n" + " --cache Same as -c\n" + " --top Show top N results in terminal summary (default: 50)\n" + " --min-count Only show functions called at least N times (default: 1)\n" + "\n" + "Multi-file format:\n" + " One target per line. Lines starting with '#' are comments.\n" + " Hex packages prefixed with 'hex:'. GitHub URLs or local paths detected\n" + " automatically.\n" + "\n" + "Examples:\n" + " spectrometer audit --github https://github.com/ninenines/cowboy\n" + " spectrometer audit --hex jsx\n" + " spectrometer audit --hex cowboy --version 3.1.0\n" + " spectrometer audit --dir /path/to/project -o report.csv\n" + " spectrometer audit --multi targets.txt --top 20\n" + ). + +%% Print help text for the 'ecosystem' command. +-spec usage_ecosystem() -> ok. +usage_ecosystem() -> + io:format( + "Usage: spectrometer ecosystem [OPTIONS]\n" + "\n" + "Scan the top Erlang GitHub repositories and/or Hex packages to gather\n" + "raw statistics about OTP function usage in the BEAM ecosystem.\n" + "Use the 'filter' command to analyze the results.\n" + "\n" + "Source selection (default: both):\n" + " --github-only Only audit GitHub repositories\n" + " --hex-only Only audit Hex packages\n" + "\n" + "Performance:\n" + " --workers Number of parallel workers (default: 4)\n" + " --limit Maximum number of repos/packages to audit\n" + " --stars Minimum number of stars for GitHub repos (default: 1)\n" + "\n" + "State:\n" + " --resume Resume from a previous audit\n" + " --cache-dir Directory to store beam_ecosystem data file (beam_ecosystem.bin)\n" + "\n" + "Examples:\n" + " spectrometer ecosystem\n" + " spectrometer ecosystem --github-only --limit 100\n" + " spectrometer ecosystem --hex-only --workers 8 --resume\n" + ). + +%% Print help text for the 'examine' command. +-spec usage_examine() -> ok. +usage_examine() -> + io:format( + "Usage: spectrometer examine [TARGET] [OPTIONS]\n" + "\n" + "Examine a single target, or a list of targets from a file for OTP M:F/A usage statistics.\n" + "\n" + "Target (exactly one):\n" + " --github GitHub repository URL (e.g. https://github.com/ninenines/cowboy)\n" + " --hex Hex package name (optionally with --version)\n" + " --dir Local directory containing .erl source files\n" + " --multi File with one target per line (see format below)\n" + "\n" + "Options:\n" + " -o Write full CSV report to file\n" + " --output Same as -o\n" + " -c Use alternate cache directory for supported functions DB\n" + " --cache Same as -c\n" + " --top Show top N results in terminal summary (default: 50)\n" + " --min-count Only show functions called at least N times (default: 1)\n" + "\n" + "Multi-file format:\n" + " One target per line. Lines starting with '#' are comments.\n" + " Hex packages prefixed with 'hex:'. GitHub URLs or local paths detected\n" + " automatically.\n" + "\n" + "Examples:\n" + " spectrometer examine --github https://github.com/ninenines/cowboy\n" + " spectrometer examine --hex jsx\n" + " spectrometer examine --hex cowboy --version 3.1.0\n" + " spectrometer examine --dir /path/to/project -o report.csv\n" + " spectrometer examine --multi targets.txt --top 20\n" + "\n" + ). + +%% Print help text for the 'supported' command. +-spec usage_supported() -> ok. +usage_supported() -> + io:format( + "Usage: spectrometer supported [OPTIONS]\n" + "\n" + "List all OTP functions that AtomVM currently supports.\n" + "\n" + "Options:\n" + " --module Show functions for a specific OTP module\n" + " -m Same as --module\n" + " -c Use alternate cache directory for supported functions DB\n" + " --cache Same as -c\n" + "\n" + "Examples:\n" + " spectrometer supported\n" + " spectrometer supported --module gen_server\n" + " spectrometer supported -m lists\n" + " spectrometer supported -c /tmp/custom_cache\n" + "\n" + ). + +%% Print help text for the 'filter' command. +-spec usage_filter() -> ok. +usage_filter() -> + io:format( + "Usage: spectrometer filter [OPTIONS]\n" + "\n" + "Filter ecosystem audit results to show OTP function usage statistics.\n" + "Loads from the ecosystem binary state file unless --csv is specified.\n" + "\n" + "Options:\n" + " --min-repos Only show functions used by >= N repos (default: 1)\n" + " --avm Filter to show only AtomVM unsupported functions\n" + " -c Use alternate cache directory for supported functions DB\n" + " --cache Same as -c\n" + "\n" + "Examples:\n" + " spectrometer filter\n" + " spectrometer filter --min-repos 10\n" + " spectrometer filter --avm\n" + " spectrometer filter --avm --min-repos 5\n" + " spectrometer filter --csv results.csv --min-repos 10\n" + ). + +%% Print help text for the 'update' command. +-spec usage_update() -> ok. +usage_update() -> + io:format( + "Usage: spectrometer update [OPTIONS]\n" + "\n" + "Scan an AtomVM source tree and regenerate the supported functions\n" + "database. Writes the result as a .term file.\n" + "\n" + "Source selection:\n" + " --atomvm-dir Path to a local AtomVM clone (read-only, ignores --branch/--tag)\n" + " Default: clones https://github.com/atomvm/AtomVM to a temp dir\n" + "\n" + "Branch/tag selection (only for remote clone, ignored with --atomvm-dir):\n" + " --branch Branch to checkout (default: main)\n" + " --tag Tag to checkout\n" + "\n" + "Options:\n" + " --output Write to specific file instead of cache directory\n" + " -c Use alternate cache directory for supported functions DB\n" + " --cache Same as -c\n" + " --no-tests Skip scanning test files for external calls\n" + " --force Overwrite existing database without confirmation\n" + "\n" + "Examples:\n" + " spectrometer update\n" + " spectrometer update --atomvm-dir /home/user/work/AtomVM\n" + " spectrometer update --branch release-0.6\n" + " spectrometer update --tag v0.6.5 --output /home/user/custom_db.term\n" + " spectrometer update --cache /tmp/custom_cache\n" + ). + +%% Print help text for the 'query' command. +-spec usage_query() -> ok. +usage_query() -> + io:format( + "Usage: spectrometer query [OPTIONS]\n" + "\n" + "Query whether a specific OTP function is supported by AtomVM and on\n" + "which platforms it is available.\n" + "\n" + "Arguments:\n" + " Module:Function Show all supported arities for the function\n" + " Module:Function/Arity Show support for a specific arity\n" + "\n" + "Options:\n" + " -c Use alternate cache directory for supported functions DB\n" + " --cache Same as -c\n" + "\n" + "Examples:\n" + " spectrometer query lists:map\n" + " spectrometer query lists:map/2\n" + " spectrometer query gen_server:call/3\n" + " spectrometer query file:read_file\n" + " spectrometer query -c /tmp/custom_cache mock_pkg:custom_func/1\n" + ). diff --git a/src/spectrometer_http.erl b/src/spectrometer_http.erl new file mode 100644 index 0000000..b5045a6 --- /dev/null +++ b/src/spectrometer_http.erl @@ -0,0 +1,513 @@ +%% +%% 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_http). + +-moduledoc """ +HTTP fetching for GitHub repos and Hex packages. + +This module provides the network layer for ecosystem scans and target resolution. +It uses `httpc` for all HTTP operations (no external CLI dependencies like `gh`). + +GitHub repos are fetched via the GitHub Search API with cursor-based pagination +by star count. Hex packages are fetched via the Hex API sorted by total downloads. +""". + +-export([ + fetch_github_repos/1, + fetch_hex_packages/1, + fetch/1, + download_github_repo/2, + download_hex_tarball/2 +]). + +-define(GITHUB_PER_PAGE, 100). +-define(GITHUB_MAX_PER_QUERY, 1000). +-define(HEX_PER_PAGE, 100). +-define(HEX_MAX_PAGES, 100). + +-doc """ +Fetch GitHub repos via the GitHub Search API with cursor-based pagination. + +Fetches Erlang repositories sorted by star count, up to `Limit` repos. +Pass `infinity` to fetch all available repos (capped at the API's +pagination limits). +""". +fetch_github_repos({Limit, MinStars}) -> + io:format("Fetching GitHub repos...\n"), + Max = + case Limit of + infinity -> ?GITHUB_MAX_PER_QUERY * 15; + _ -> Limit + end, + Repos = fetch_github_cursor(MinStars, undefined, [], Max), + io:format(" Total: ~p GitHub repos\n", [length(Repos)]), + Repos. + +-doc false. +%% Cursor-based GitHub repo fetching by star count range. +fetch_github_cursor(_MinStars, _LastStars, Acc, Max) when length(Acc) >= Max -> + lists:sublist(Acc, Max); +fetch_github_cursor(MinStars, LastStars, Acc, Max) when LastStars < MinStars -> + lists:sublist(Acc, Max); +fetch_github_cursor(MinStars, LastStars, Acc, Max) -> + Range = star_filter_range(MinStars, LastStars), + Remaining = Max - length(Acc), + %% Add +2 to fetch, because "erlang/otp" and "atomvm/AtomVM" are filtered from results. + Fetch = min(Remaining + 2, ?GITHUB_MAX_PER_QUERY), + io:format(" stars:~s ...", [Range]), + {Repos0, TotalCount} = fetch_github_query(Range, Fetch), + Repos = filter_repos(Repos0, []), + io:format(" ~p repos (of ~p available)\n", [length(Repos), TotalCount]), + case Repos of + [] -> + Acc; + _ -> + NewAcc = Acc ++ Repos, + case length(NewAcc) >= Max of + true -> + lists:sublist(NewAcc, Max); + false -> + Stars = [maps:get(stars, R) || R <- Repos], + MinStarsInBatch = lists:min(Stars), + fetch_github_cursor( + MinStars, MinStarsInBatch - 1, NewAcc, Max + ) + end + end. + +star_filter_range(infinity, _LastStars) -> + ">=1"; +star_filter_range(MinStars, undefined) -> + io_lib:format(">=~p", [MinStars]); +star_filter_range(MinStars, LastStars) -> + io_lib:format("~p..~p", [MinStars, LastStars]). + +filter_repos([], Acc) -> + lists:reverse(Acc); +filter_repos([Repo | Rest], Acc) -> + case string:find(maps:get(full_name, Repo), "erlang/otp") of + nomatch -> + case string:find(maps:get(full_name, Repo), "atomvm/AtomVM") of + nomatch -> + filter_repos(Rest, [Repo | Acc]); + _ -> + filter_repos(Rest, Acc) + end; + _ -> + filter_repos(Rest, Acc) + end. + +-doc false. +%% Fetch repos for a single star range query. +fetch_github_query(StarRange, Max) -> + Query = lists:flatten("language:Erlang stars:" ++ StarRange), + Limit = min(Max, ?GITHUB_MAX_PER_QUERY), + fetch_github_pages(Query, 1, [], Limit, 0). + +-doc false. +%% Paginated GitHub API fetcher. +fetch_github_pages(_Query, _Page, Acc, Max, TC) when length(Acc) >= Max -> + {lists:sublist(lists:reverse(Acc), Max), TC}; +fetch_github_pages(_Query, Page, Acc, _Max, TC) when + Page > (?GITHUB_MAX_PER_QUERY div ?GITHUB_PER_PAGE) +-> + {lists:reverse(Acc), TC}; +fetch_github_pages(Query, Page, Acc, Max, TC) -> + Url = io_lib:format( + "https://api.github.com/search/repositories" + "?q=~s" + "&sort=stars" + "&order=desc" + "&per_page=~p" + "&page=~p", + [uri_string:quote(Query), ?GITHUB_PER_PAGE, Page] + ), + case fetch(lists:flatten(Url)) of + {ok, Body} -> + try + case json:decode(Body) of + #{<<"total_count">> := NewTC, <<"items">> := Items} when + is_list(Items), length(Items) > 0 + -> + Repos = lists:map( + fun(Item) -> + #{ + full_name => binary_to_list( + maps:get(<<"full_name">>, Item) + ), + clone_url => binary_to_list( + maps:get(<<"clone_url">>, Item) + ), + html_url => binary_to_list( + maps:get(<<"html_url">>, Item) + ), + stars => maps:get( + <<"stargazers_count">>, Item, 0 + ) + } + end, + Items + ), + fetch_github_pages( + Query, + Page + 1, + lists:reverse(Repos) ++ Acc, + Max, + NewTC + ); + _ -> + {lists:reverse(Acc), TC} + end + catch + _:_ -> + {lists:reverse(Acc), TC} + end; + {error, _Reason} -> + {lists:reverse(Acc), TC} + end. + +-doc """ +Fetch Hex packages via the Hex API sorted by total downloads. + +Fetches Erlang packages up to `Limit`. Pass `infinity` to fetch all +available packages (capped at API pagination limits). +""". +fetch_hex_packages(Limit) -> + Max = + case Limit of + infinity -> ?HEX_MAX_PAGES * ?HEX_PER_PAGE; + _ -> min(Limit, ?HEX_MAX_PAGES * ?HEX_PER_PAGE) + end, + io:format("Fetching Hex packages (up to ~p)...\n", [Max]), + fetch_hex_pages(1, [], Max). + +-doc false. +%% Paginated Hex API fetcher. +fetch_hex_pages(Page, Acc, Max) when + Page > ?HEX_MAX_PAGES; length(Acc) >= Max +-> + Packages = lists:sublist(lists:reverse(Acc), Max), + io:format(" Found ~p Hex packages\n", [length(Packages)]), + Packages; +fetch_hex_pages(Page, Acc, Max) -> + Url = io_lib:format( + "https://hex.pm/api/packages?sort=total_downloads&per_page=~p&page=~p", + [?HEX_PER_PAGE, Page] + ), + case fetch(lists:flatten(Url)) of + {ok, Body} -> + try + case json:decode(Body) of + Items when is_list(Items), length(Items) > 0 -> + Packages = lists:filtermap( + fun(Item) -> + Name = binary_to_list( + maps:get(<<"name">>, Item, <<>>) + ), + Meta = maps:get(<<"meta">>, Item, #{}), + Links = maps:get(<<"links">>, Meta, #{}), + GithubUrl = find_github_link(Links), + LatestVersion = binary_to_list( + maps:get(<<"latest_version">>, Item, <<>>) + ), + case LatestVersion of + "" -> + false; + _ -> + {true, #{ + name => Name, + version => LatestVersion, + github_url => GithubUrl + }} + end + end, + Items + ), + io:format(" Page ~p: ~p packages\n", [ + Page, length(Packages) + ]), + fetch_hex_pages( + Page + 1, lists:reverse(Packages) ++ Acc, Max + ); + _ -> + lists:reverse(Acc) + end + catch + _:_ -> + lists:reverse(Acc) + end; + {error, Reason} -> + io:format(" Page ~p: HTTP error: ~p\n", [Page, Reason]), + lists:reverse(Acc) + end. + +-doc false. +%% Extract GitHub URL from package links map. +find_github_link(Links) when is_map(Links) -> + maps:fold( + fun(_Key, Value, Acc) -> + case Acc of + "" -> + case is_binary(Value) of + true -> + Url = binary_to_list(Value), + case string:find(Url, "github.com") of + nomatch -> ""; + _ -> Url + end; + false -> + "" + end; + _ -> + Acc + end + end, + "", + Links + ); +find_github_link(_) -> + "". + +-doc """ +Clone a GitHub repo to a temporary directory using a shallow clone. + +Sets `GIT_TERMINAL_PROMPT=0` to prevent credential prompts in CI. +Returns `ok` on success, `{error, {clone_failed, Status}}` on failure. +""". +-ifdef(TEST). +-define(GIT_OPTS, [{"GIT_ASKPASS", "false"}, {"GIT_TERMINAL_PROMPT", "0"}]). +-else. +-define(GIT_OPTS, [{"GIT_TERMINAL_PROMPT", "0"}]). +-endif. +download_github_repo(CloneUrl, TmpDir) -> + case os:find_executable("git") of + false -> + {error, git_not_found}; + GitPath -> + Port = open_port( + {spawn_executable, GitPath}, + [ + {args, [ + "clone", "--depth", "1", "--quiet", CloneUrl, TmpDir + ]}, + {env, ?GIT_OPTS}, + exit_status + ] + ), + case await_git_port(Port) of + 0 -> ok; + {error, clone_timeout} -> {error, clone_timeout}; + Status -> {error, {clone_failed, Status}} + end + end. + +-doc false. +%% Wait for git port to complete and return exit status. +await_git_port(Port) -> + receive + {Port, {exit_status, Status}} -> Status + after 180000 -> + port_close(Port), + drain_port_messages(Port), + {error, clone_timeout} + end. + +-doc false. +%% Drain any pending messages for a closed port to avoid mailbox pollution. +drain_port_messages(Port) -> + receive + {Port, {exit_status, _}} -> ok + after 0 -> + ok + end. + +-doc """ +Download and extract a Hex package tarball. + +Fetches the tarball from `repo.hex.pm`, extracts the nested `contents.tar.gz`, +and checks for `.erl` files. Returns `{ok, TmpDir}` on success with the +extracted contents in a temp directory, or `{error, Reason}` on failure. +""". +download_hex_tarball(Name, Version) -> + Url = lists:flatten( + io_lib:format( + "https://repo.hex.pm/tarballs/~s-~s.tar", + [Name, Version] + ) + ), + Hostname = hostname_from_url(Url), + case + httpc:request( + get, + {Url, [{"user-agent", "atomvm_spectrometer/1.0"}]}, + [ + {timeout, 30000}, + {connect_timeout, 10000}, + {ssl, ssl_options(Hostname)} + ], + [{body_format, binary}] + ) + of + {ok, {{_, 200, _}, _, Body}} -> + process_hex_tarball(Body, Name); + {ok, {{_, Code, _}, _, _}} -> + {error, {http_status, Code}}; + {error, Reason} -> + {error, Reason} + end. + +-doc false. +%% Extract and validate a Hex tarball in memory. +%% Checks for contents.tar.gz and verifies .erl files exist. +%% Validates archive entries to prevent path traversal attacks. +process_hex_tarball(TarBin, _Name) -> + case erl_tar:extract({binary, TarBin}, [memory]) of + {ok, OuterFiles} -> + case lists:keyfind("contents.tar.gz", 1, OuterFiles) of + {"contents.tar.gz", ContentsTarGz} -> + case erl_tar:table({binary, ContentsTarGz}, [compressed]) of + {ok, FileList} -> + HasErl = lists:any( + fun(F) -> + filename:extension(F) =:= ".erl" + end, + FileList + ), + case HasErl of + true -> + case validate_tar_paths(FileList) of + ok -> + TmpDir = spectrometer_utils:make_temp_dir( + "hex_" + ), + try + case + erl_tar:extract( + {binary, ContentsTarGz}, + [ + {cwd, TmpDir}, + compressed + ] + ) + of + ok -> {ok, TmpDir}; + {error, R} -> {error, R} + end + catch + _:_ -> + _ = spectrometer_utils:purge_dir( + TmpDir + ), + {error, extract_failed} + end; + {error, Reason} -> + {error, Reason} + end; + false -> + {error, no_erl_files} + end; + _ -> + {error, no_erl_files} + end; + false -> + {error, no_contents_tar} + end; + {error, Reason} -> + {error, {tar_extract, Reason}} + end. + +-doc false. +%% Validate tarball entry paths to prevent path traversal attacks. +%% Rejects absolute paths, ".." segments, and ensures paths stay within TmpDir. +validate_tar_paths(Paths) -> + case lists:all(fun validate_tar_path/1, Paths) of + true -> ok; + false -> {error, path_traversal_attempt} + end. + +-doc false. +%% Validate a single tarball entry path. +%% Returns true if the path is safe (relative, no ".." segments). +validate_tar_path(Path) -> + % Reject empty paths + Path =/= [] andalso + % Reject absolute Unix paths (starting with /) + string:left(Path, 1) =/= "/" andalso + % Reject absolute Windows paths (starting with drive letter like C:\) + not is_windows_absolute_path(Path) andalso + % Reject path segments with ".." (handle both / and \ separators) + not has_dotdot_segment(Path). + +-doc false. +%% Check if path looks like an absolute Windows path (C:\...). +is_windows_absolute_path([Drive, $:, Sep | _]) when + Drive >= $A, Drive =< $Z, (Sep == $\\ orelse Sep == $/) +-> + true; +is_windows_absolute_path([Drive, $:, Sep | _]) when + Drive >= $a, Drive =< $z, (Sep == $\\ orelse Sep == $/) +-> + true; +is_windows_absolute_path(_) -> + false. + +-doc false. +%% Check if path contains ".." as a path segment. +%% Normalizes separators before checking to prevent bypass with mixed separators +%% like "a\\..//secret.erl" which could produce ".." segment. +has_dotdot_segment(Path) -> + % Normalize all backslashes to forward slashes first + Normalized = re:replace(Path, "\\\\", "/", [{return, list}, global]), + Segments = string:split(Normalized, "/", all), + lists:member("..", Segments). + +-doc false. +%% Extract hostname from a URL for SNI. +hostname_from_url(Url) -> + #{host := Host} = uri_string:parse(Url), + Host. + +-doc false. +%% SSL options with peer verification. +ssl_options(Hostname) -> + Certs = public_key:cacerts_get(), + [ + {verify, verify_peer}, + {cacerts, Certs}, + {depth, 3}, + {server_name_indication, Hostname}, + {customize_hostname_check, [ + {match_fun, public_key:pkix_verify_hostname_match_fun(https)} + ]} + ]. + +-doc false. +%% Fetch a URL and return the body on success. +fetch(Url) -> + Hostname = hostname_from_url(Url), + case + httpc:request( + get, + {Url, [{"user-agent", "atomvm_spectrometer/1.0"}]}, + [ + {timeout, 30000}, + {connect_timeout, 10000}, + {ssl, ssl_options(Hostname)} + ], + [{body_format, binary}] + ) + of + {ok, {{_, 200, _}, _, Body}} -> + {ok, Body}; + {ok, {{_, Code, _}, _, _}} -> + {error, {http_status, Code}}; + {error, Reason} -> + {error, Reason} + end. diff --git a/src/spectrometer_otp.erl b/src/spectrometer_otp.erl new file mode 100644 index 0000000..056eec5 --- /dev/null +++ b/src/spectrometer_otp.erl @@ -0,0 +1,116 @@ +%% +%% 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). + +-moduledoc """ +This module contains functions for identifying OTP modules. +""". + +-export([is_otp_module/1, modules_list/0]). + +-doc """ +Returns `true` if the module is an OTP module, otherwise `false`. +""". +-spec is_otp_module(atom() | string()) -> boolean(). +is_otp_module(Atom) when is_atom(Atom) -> + is_otp_module(atom_to_list(Atom)); +is_otp_module(AtomStr) when is_list(AtomStr) -> + OTPmods = modules_list(), + lists:member(AtomStr, OTPmods). + +-doc """ +Returns a list of module for the running OTP version. + +Uses a cached file if it exists, attempting to create a cached list of modules +if one does not exist. Falls back to generating the list at runtime on failures. +""". +-spec modules_list() -> [string()]. +modules_list() -> + ModFile = module_cache(), + case filelib:is_file(ModFile) of + true -> + case file:read_file(ModFile) of + {ok, Bin} -> + try binary_to_term(Bin) of + Modules when is_list(Modules) -> + case + lists:all( + fun(List) -> + io_lib:printable_list(List) + end, + Modules + ) + of + true -> + Modules; + false -> + io:format( + "Warning: invalid module identifiers in OTP module cache ~s, regenerating...\n", + [ModFile] + ), + regenerate_and_write(ModFile) + end; + _ -> + io:format( + "Warning: unexpected data in OTP module cache ~s, regenerating...\n", + [ModFile] + ), + regenerate_and_write(ModFile) + catch + _:_ -> + io:format( + "Warning: error decoding OTP module cache file ~s\n", + [ModFile] + ), + io:format("Regenerating OTP module cache...\n"), + regenerate_and_write(ModFile) + end; + {error, Reason} -> + io:format( + "Error reading OTP module cache file ~s: ~p\n", + [ModFile, Reason] + ), + io:format("Regenerating OTP module cache...\n"), + regenerate_and_write(ModFile) + end; + false -> + regenerate_and_write(ModFile) + end. + +%% Helper to generate module list and write to cache file +regenerate_and_write(ModFile) -> + Modules = [M || {M, _, _} <- code:all_available()], + case filelib:ensure_dir(ModFile) of + ok -> + case file:write_file(ModFile, term_to_binary(Modules)) of + ok -> + ok; + {error, Reason} -> + io:format( + "Warning: Unable to write to otp module data file ~s, reason: ~p\n", + [ModFile, Reason] + ), + ok + end; + {error, Reason} -> + io:format( + "Warning: Unable to create cache dir for OTP module data ~s, reason: ~p\n", + [ModFile, Reason] + ), + ok + end, + Modules. + +%% Get the cache file path for OTP modules +-spec module_cache() -> file:filename_all(). +module_cache() -> + VersionString = erlang:system_info(otp_release), + CacheDir = spectrometer_utils:user_cache_path(), + filename:join(CacheDir, "otp_" ++ VersionString ++ "_modules.bin"). diff --git a/src/spectrometer_reporter.erl b/src/spectrometer_reporter.erl new file mode 100644 index 0000000..84b69e5 --- /dev/null +++ b/src/spectrometer_reporter.erl @@ -0,0 +1,366 @@ +%% +%% 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_reporter). + +-include_lib("kernel/include/file.hrl"). + +-moduledoc """ +Generates portability audit reports of OTP function usage. + +This module is responsible for user-facing reporting. It takes scan statistics +and splits them into supported and unsupported functions (based on the AtomVM +database), prints terminal summaries ordered by call frequency, and writes +CSV output for further analysis. + +Only OTP (non-local) functions are included in reports — the module uses a +heuristic list of known OTP module names to filter out application-specific +code. +""". + +-export([ + generate_report/1, + generate_report/2, + print_summary/1, + print_summary/3, + write_csv/2, + write_csv/3 +]). + +-doc """ +Generate a full report with default options (min_count = 1). + +Delegates to `generate_report/2` with `#{min_count => 1}`. +""". +-spec generate_report(#{ + {atom(), atom(), non_neg_integer()} => non_neg_integer() +}) -> + #{ + 'supported' => [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + 'unsupported' => [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + 'total' => non_neg_integer(), + 'total_unique' => non_neg_integer() + }. +generate_report(Stats) -> + generate_report(Stats, 1). + +-doc """ +Generate a full report with options. + +Filters the scan statistics to OTP functions only, splits them into +supported and unsupported lists, and applies the `min_count` filter. +Returns a map with `supported`, `unsupported`, `total`, and `total_unique` +keys. + +#### Options + +- `min_count` — Minimum call count to include (default: 1) +""". +-spec generate_report( + #{{atom(), atom(), non_neg_integer()} => non_neg_integer()}, + non_neg_integer() +) -> + #{ + supported := [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + unsupported := [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + total := non_neg_integer(), + total_unique := non_neg_integer() + }. +generate_report(Stats, MinCount) -> + OtpStats = filter_otp_functions(Stats), + Unsupported = spectrometer_atomvm:get_unsupported(OtpStats), + Supported = lists:filter( + fun({Key, _Count}) -> + spectrometer_atomvm:is_supported(Key) + end, + lists:sort(fun({_, C1}, {_, C2}) -> C1 > C2 end, maps:to_list(OtpStats)) + ), + FilteredUnsupported = lists:filter( + fun({_, Count}) -> Count >= MinCount end, Unsupported + ), + FilteredSupported = lists:filter( + fun({_, Count}) -> Count >= MinCount end, Supported + ), + + TotalCalls = + lists:sum([C0 || {_, C0} <- FilteredUnsupported]) + + lists:sum([C1 || {_, C1} <- FilteredSupported]), + TotalUnique = length(FilteredUnsupported ++ FilteredSupported), + + #{ + supported => FilteredSupported, + total => TotalCalls, + total_unique => TotalUnique, + unsupported => FilteredUnsupported + }. + +-doc false. +%% Filter statistics to only OTP (non-local) functions. +%% Uses a heuristic set of known OTP module names. +filter_otp_functions(Stats) -> + OtpModules = get_otp_module_set(), + maps:filter( + fun({Mod, _Fun, _Arity}, _Count) -> + sets:is_element(Mod, OtpModules) + end, + Stats + ). + +-doc false. +%% Get or generate the OTP module set with caching. +%% Checks for a cached version first, generates if not found. +-spec get_otp_module_set() -> sets:set(atom()). +get_otp_module_set() -> + OtpMods = spectrometer_otp:modules_list(), + %% Convert string module names to atoms for matching + OtpAtoms = [spectrometer_utils:atom_from_string(Mod) || Mod <- OtpMods], + sets:from_list(OtpAtoms, [{version, 2}]). + +-doc """ +Print a terminal summary with default top count (50). +""". +-spec print_summary(#{ + supported := [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + unsupported := [{{atom(), atom(), arity()}, non_neg_integer()}], + total := non_neg_integer(), + total_unique := non_neg_integer() +}) -> ok. +print_summary(Report) -> + print_summary(Report, 50, false), + ok. + +-doc """ +Print a terminal summary with configurable top count. + +Displays the top `TopN` unsupported functions ordered by call count, +with totals. +""". +-spec print_summary( + #{ + supported := [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + unsupported := [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + total := non_neg_integer(), + total_unique := non_neg_integer() + }, + TopN :: pos_integer(), + OnlyUnsupported :: true | false +) -> ok. +print_summary(Report, TopN, true) -> + #{ + unsupported := Unsupported, + supported := _, + total_unique := _TotalUnique + } = + Report, + UnsupportedTotal = lists:sum([Count || {{_, _, _}, Count} <- Unsupported]), + TopList = lists:sublist(Unsupported, TopN), + + io:format("\n"), + io:format("~s\n", [string:copies("=", 80)]), + io:format(" AtomVM Portability Audit — Unsupported OTP Functions\n"), + io:format("~s\n", [string:copies("=", 80)]), + io:format( + " Total unsupported unique functions: ~p (~p total calls)\n", + [length(Unsupported), UnsupportedTotal] + ), + io:format("~s\n", [string:copies("-", 80)]), + + case TopList of + [] -> + io:format( + " All top ~p scanned OTP functions are supported by AtomVM!\n", + [TopN] + ); + _ -> + io:format(" ~-4s ~-40s ~10s\n", [ + "", "Module:Function/Arity", "Calls" + ]), + io:format(" ~s\n", [string:copies("-", 80)]), + lists:foldl( + fun({{Mod, Fun, Arity}, Count}, Idx) -> + MFA = io_lib:format("~ts:~ts/~p", [Mod, Fun, Arity]), + MFAList = lists:flatten(MFA), + io:format( + " ~-4w ~-40s ~10w\n", + [Idx, MFAList, Count] + ), + Idx + 1 + end, + 1, + TopList + ), + case length(Unsupported) > TopN of + true -> + io:format( + " ... and ~p more (use higher --top count to see more)\n", + [length(Unsupported) - TopN] + ); + false -> + ok + end + end, + ok = io:format("~s\n", [string:copies("=", 80)]); +print_summary(Report, TopN, false) -> + Supported = maps:get(supported, Report), + Unsupported = maps:get(unsupported, Report), + Sorted = sort_stats(Supported ++ Unsupported), + Results = lists:sublist(Sorted, TopN), + io:format("\n"), + io:format("~s\n", [string:copies("=", 78)]), + io:format(" Top ~p Most Used Erlang/OTP Functions\n", [ + min(TopN, length(Results)) + ]), + io:format("~s\n", [string:copies("=", 78)]), + io:format("~4s ~-40s ~10s\n", [ + "#", "Module:Function/Arity", "Calls" + ]), + io:format("~s\n", [string:copies("-", 78)]), + lists:foldl( + fun({{Mod, Fun, Arity}, Count}, Idx) -> + MFA = io_lib:format("~ts:~ts/~p", [Mod, Fun, Arity]), + io:format("~4p ~-40ts ~10p\n", [ + Idx, lists:flatten(MFA), Count + ]), + Idx + 1 + end, + 1, + Results + ), + io:format("~s\n", [string:copies("=", 78)]), + ok = io:format("Total unique MFAs: ~p\n", [length(Sorted)]). + +sort_stats(Stats) -> + lists:sort(fun({_, C1}, {_, C2}) -> C1 > C2 end, Stats). + +-doc false. +%% Quote a field for CSV output. +%% Wraps in double quotes if contains comma, double-quote, or newline, +%% and doubles any internal double quotes per RFC 4180. +-spec quote_csv_field(string()) -> string(). +quote_csv_field(Field) -> + case needs_csv_quoting(Field) of + true -> + Quoted = string:replace(Field, "\"", "\"\"", all), + "\"" ++ Quoted ++ "\""; + false -> + Field + end. + +-doc false. +%% Check if a field needs CSV quoting. +-spec needs_csv_quoting(string()) -> boolean(). +needs_csv_quoting(Field) -> + string:find(Field, ",") =/= nomatch orelse + string:find(Field, "\"") =/= nomatch orelse + string:find(Field, [10]) =/= nomatch. + +-doc """ +Write CSV output with all unsupported functions. +""". +-spec write_csv( + string(), + #{ + supported := [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + unsupported := [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + total := non_neg_integer(), + total_unique := non_neg_integer() + } +) -> ok | {error, term()}. +write_csv(File, Report) -> + write_csv(File, Report, all). + +-doc """ +Write CSV output with a limit on the number of unsupported functions. + +Pass `all` as `Limit` to include all unsupported functions. +""". +-spec write_csv( + string(), + #{ + supported := [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + unsupported := [ + {{atom(), atom(), non_neg_integer()}, non_neg_integer()} + ], + total := non_neg_integer(), + total_unique := non_neg_integer() + }, + pos_integer() | all +) -> ok | {error, term()}. +write_csv(File, Report, all) -> + #{unsupported := Unsupported} = Report, + do_write_csv(File, Unsupported); +write_csv(File, Report, Limit) when is_integer(Limit), Limit > 0 -> + #{unsupported := Unsupported} = Report, + Limited = lists:sublist(Unsupported, Limit), + do_write_csv(File, Limited). + +-doc false. +%% Internal CSV writer — opens file, writes header and rows, closes. +-spec do_write_csv(file:name_all(), [ + {{atom(), atom(), arity()}, non_neg_integer()} +]) -> ok | {error, term()}. +do_write_csv(File, Unsupported) -> + try + case file:open(File, [write, {encoding, utf8}]) of + {error, Reason0} -> + io:format(" Failed to open file ~s for writing: ~p\n", [ + File, Reason0 + ]), + error(Reason0); + {ok, Fd} -> + io:format( + Fd, "module,function,arity,calls,atomvm_supported\n", [] + ), + lists:foreach( + fun({{Mod, Fun, Arity}, Count}) -> + io:format(Fd, "~s,~s,~p,~p,no\n", [ + quote_csv_field(atom_to_list(Mod)), + quote_csv_field(atom_to_list(Fun)), + Arity, + Count + ]) + end, + Unsupported + ), + case file:close(Fd) of + ok -> + ok; + {error, Reason1} -> + io:format(" Failed to close file ~s: ~p\n", [ + File, Reason1 + ]), + error(Reason1) + end, + io:format(" Results written to ~s\n", [File]) + end + catch + error:Reason -> + {error, Reason} + end. diff --git a/src/spectrometer_scanner.erl b/src/spectrometer_scanner.erl new file mode 100644 index 0000000..c0a0e82 --- /dev/null +++ b/src/spectrometer_scanner.erl @@ -0,0 +1,369 @@ +%% +%% 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_scanner). + +-moduledoc """ +Scans directories for Erlang source files and extracts function call statistics. + +This module is the core scanning engine used by all scan operations. It +discovers `.erl` files in a directory tree (skipping symlinks), parses them +using `epp_dodger` and `erl_syntax_lib` for robust handling of malformed +source code, and extracts `Module:Function/Arity` call statistics. + +The result is a map from `{Module, Function, Arity}` tuples to call counts, +which is consumed by the analyzer and reporter modules. +""". + +-export([scan_directory/1, parse_calls/1]). + +-include_lib("kernel/include/file.hrl"). + +-doc """ +Scan a directory tree for Erlang source files and return function call statistics. + +Walks the directory tree recursively, skipping symlinks to avoid infinite +loops. For each `.erl` file found, parses the source and extracts all +`Module:Function(...)` calls and `fun Module:Function/Arity` references. +BIF calls (e.g. `length/1`) are attributed to the `erlang` module. + +Returns a map where keys are `{Module, Function, Arity}` tuples and values +are the number of times that function was called across all files. + +#### Example + +```erlang +1> spectrometer_scanner:scan_directory("/path/to/project"). +#{{lists,map,2} => 42, {io,format,2} => 17, ...} +``` +""". +-spec scan_directory(Dir :: string()) -> + #{{module(), atom(), non_neg_integer()} => non_neg_integer()}. +scan_directory(Dir) -> + ErlFiles = find_erl_files(Dir), + lists:foldl( + fun(File, Acc) -> + case parse_file(File) of + {ok, Calls} -> + merge_file_calls(Calls, Acc); + {error, _} -> + Acc + end + end, + #{}, + ErlFiles + ). + +-doc false. +%% Recursively find .erl files in a directory, skipping symlinks. +find_erl_files(Dir) -> + find_erl_files(Dir, []). + +-doc false. +%% Accumulator variant of find_erl_files/1. +find_erl_files(Dir, Acc) -> + case file:list_dir(Dir) of + {ok, Entries} -> + lists:foldl( + fun(Entry, A) -> + Path = filename:join(Dir, Entry), + case file:read_link_info(Path) of + {ok, #file_info{type = directory}} -> + case Entry of + "_build" -> A; + "deps" -> A; + ".rebar3" -> A; + ".git" -> A; + _ -> find_erl_files(Path, A) + end; + {ok, #file_info{type = regular}} -> + case filename:extension(Entry) of + ".erl" -> [Path | A]; + _ -> A + end; + _ -> + A + end + end, + Acc, + Entries + ); + {error, _} -> + Acc + end. + +-doc false. +%% Parse a single .erl file using epp_dodger for robust parsing. +%% Returns {ok, Calls} where Calls is a map of {Mod,Fun,Arity} => Count, +%% or {error, Reason} on failure. +parse_file(File) -> + try + case epp_dodger:parse_file(File) of + {ok, Forms} -> + Calls = lists:foldl( + fun extract_calls/2, + #{}, + Forms + ), + {ok, Calls}; + {error, Reason} -> + {error, Reason} + end + catch + _:Err -> + {error, Err} + end. + +-doc false. +%% Parse an Erlang file and return module name with external function calls. +%% Returns {ok, ModuleName, Calls} or {error, Reason}. +%% Calls is a map from {Module, Function, Arity} to call count. +parse_calls(File) -> + try + case epp_dodger:parse_file(File) of + {ok, Forms} -> + ModName = extract_module_name(Forms), + {ok, ModName, extract_calls_filtered(Forms, ModName)}; + {error, Reason} -> + {error, Reason} + end + catch + _:Err -> + {error, Err} + end. + +-doc false. +%% Extract the module name from parsed forms. +extract_module_name(Forms) -> + extract_module_name(Forms, undefined). + +extract_module_name([], Mod) -> + Mod; +extract_module_name([Form | Rest], _Acc) -> + case erl_syntax:type(Form) of + attribute -> + case erl_syntax:atom_value(erl_syntax:attribute_name(Form)) of + module -> + case erl_syntax:attribute_arguments(Form) of + [ModArg] -> + case erl_syntax:type(ModArg) of + atom -> erl_syntax:atom_value(ModArg); + _ -> extract_module_name(Rest, undefined) + end; + _ -> + extract_module_name(Rest, undefined) + end; + _ -> + extract_module_name(Rest, undefined) + end; + _ -> + extract_module_name(Rest, undefined) + end. + +-doc false. +%% Extract calls, filtering out calls to the same module. +extract_calls_filtered(Forms, FilterMod) -> + lists:foldl( + fun(Form, Acc) -> + erl_syntax_lib:fold( + fun(Node, A) -> + case erl_syntax:type(Node) of + application -> + extract_application_filtered(Node, A, FilterMod); + implicit_fun -> + extract_implicit_fun(Node, A, FilterMod); + _ -> + A + end + end, + Acc, + Form + ) + end, + #{}, + Forms + ). + +-doc false. +%% Extract application call, filtering out calls to FilterMod. +extract_application_filtered(Node, Acc, FilterMod) -> + Op = erl_syntax:application_operator(Node), + Args = erl_syntax:application_arguments(Node), + Arity = length(Args), + case erl_syntax:type(Op) of + module_qualifier -> + ModNode = erl_syntax:module_qualifier_argument(Op), + FunNode = erl_syntax:module_qualifier_body(Op), + case {erl_syntax:type(ModNode), erl_syntax:type(FunNode)} of + {atom, atom} -> + Mod = erl_syntax:atom_value(ModNode), + Fun = erl_syntax:atom_value(FunNode), + % Skip calls to the same module being tested + case Mod =:= FilterMod of + true -> + Acc; + false -> + Key = {Mod, Fun, Arity}, + maps:update_with(Key, fun(V) -> V + 1 end, 1, Acc) + end; + _ -> + Acc + end; + atom -> + Fun = erl_syntax:atom_value(Op), + case erl_internal:bif(Fun, Arity) of + true -> + Key = {erlang, Fun, Arity}, + maps:update_with(Key, fun(V) -> V + 1 end, 1, Acc); + false -> + Acc + end; + _ -> + Acc + end. + +-doc false. +%% Extract function calls from a parsed form by walking the syntax tree. +extract_calls(Form, Acc) -> + erl_syntax_lib:fold( + fun(Node, A) -> + case erl_syntax:type(Node) of + application -> + extract_application_call(Node, A); + implicit_fun -> + extract_implicit_fun(Node, A); + _ -> + A + end + end, + Acc, + Form + ). + +-doc false. +%% Extract Module:Function(...) application calls from a syntax node. +extract_application_call(Node, Acc) -> + Op = erl_syntax:application_operator(Node), + Args = erl_syntax:application_arguments(Node), + Arity = length(Args), + case erl_syntax:type(Op) of + module_qualifier -> + ModNode = erl_syntax:module_qualifier_argument(Op), + FunNode = erl_syntax:module_qualifier_body(Op), + case {erl_syntax:type(ModNode), erl_syntax:type(FunNode)} of + {atom, atom} -> + Mod = erl_syntax:atom_value(ModNode), + Fun = erl_syntax:atom_value(FunNode), + Key = {Mod, Fun, Arity}, + maps:update_with(Key, fun(V) -> V + 1 end, 1, Acc); + _ -> + Acc + end; + atom -> + Fun = erl_syntax:atom_value(Op), + case erl_internal:bif(Fun, Arity) of + true -> + Key = {erlang, Fun, Arity}, + maps:update_with(Key, fun(V) -> V + 1 end, 1, Acc); + false -> + Acc + end; + _ -> + Acc + end. + +-doc false. +%% Extract fun Module:Function/Arity references from a syntax node. +extract_implicit_fun(Node, Acc) -> + Name = erl_syntax:implicit_fun_name(Node), + case erl_syntax:type(Name) of + module_qualifier -> + ModNode = erl_syntax:module_qualifier_argument(Name), + Body = erl_syntax:module_qualifier_body(Name), + case erl_syntax:type(Body) of + arity_qualifier -> + FunNode = erl_syntax:arity_qualifier_body(Body), + ArityNode = erl_syntax:arity_qualifier_argument(Body), + case + { + erl_syntax:type(ModNode), + erl_syntax:type(FunNode), + erl_syntax:type(ArityNode) + } + of + {atom, atom, integer} -> + Mod = erl_syntax:atom_value(ModNode), + Fun = erl_syntax:atom_value(FunNode), + Arity = erl_syntax:integer_value(ArityNode), + Key = {Mod, Fun, Arity}, + maps:update_with(Key, fun(V) -> V + 1 end, 1, Acc); + _ -> + Acc + end; + _ -> + Acc + end; + _ -> + Acc + end. + +-doc false. +%% Extract fun Module:Function/Arity references from a syntax node, +%% filtering out references to the same module being tested. +extract_implicit_fun(Node, Acc, FilterMod) -> + Name = erl_syntax:implicit_fun_name(Node), + case erl_syntax:type(Name) of + module_qualifier -> + ModNode = erl_syntax:module_qualifier_argument(Name), + Body = erl_syntax:module_qualifier_body(Name), + case erl_syntax:type(Body) of + arity_qualifier -> + FunNode = erl_syntax:arity_qualifier_body(Body), + ArityNode = erl_syntax:arity_qualifier_argument(Body), + case + { + erl_syntax:type(ModNode), + erl_syntax:type(FunNode), + erl_syntax:type(ArityNode) + } + of + {atom, atom, integer} -> + Mod = erl_syntax:atom_value(ModNode), + Fun = erl_syntax:atom_value(FunNode), + Arity = erl_syntax:integer_value(ArityNode), + % Skip references to the same module being tested + case Mod =:= FilterMod of + true -> + Acc; + false -> + Key = {Mod, Fun, Arity}, + maps:update_with( + Key, fun(V) -> V + 1 end, 1, Acc + ) + end; + _ -> + Acc + end; + _ -> + Acc + end; + _ -> + Acc + end. + +-doc false. +%% Merge per-file call statistics into the repository accumulator. +merge_file_calls(FileCalls, RepoAcc) -> + maps:fold( + fun(Key, Count, Acc) -> + maps:update_with(Key, fun(V) -> V + Count end, Count, Acc) + end, + RepoAcc, + FileCalls + ). diff --git a/src/spectrometer_updater.erl b/src/spectrometer_updater.erl new file mode 100644 index 0000000..095eea3 --- /dev/null +++ b/src/spectrometer_updater.erl @@ -0,0 +1,1026 @@ +%% +%% 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_updater). + +-moduledoc """ +Scans AtomVM source trees to auto-generate the supported functions database +with platform and version information. + +This module parses multiple sources within an AtomVM checkout to discover +which OTP functions are supported: + +- **gperf files** (`bifs.gperf`, `nifs.gperf`) — BIF and NIF registration +tables, available on all platforms. +- **Platform NIFs** (`src/platforms/*/platform_nifs.c`) — platform-specific +NIFs. +- **Erlang library sources** (`libs/*/src/*.erl`) — `-export` directives with +platform scoping based on library location. +- **Test files** (`tests/erlang_tests/*.erl`, `tests/libs/*/*.erl`) — test +files that call OTP functions. + +### Platform Scoping Rules + +- gperf files: `all` platforms +- Core libs (alisp, estdlib, etest, exavmlib, jit, gleam_avm): `all` platforms +- eavmlib (general): `all` platforms +- eavmlib/\\*_hal.erl: esp32, stm32, rp2 only +- avm_esp32, esp32boot, esp32devmode: esp32 only +- avm_network: esp32, rp2, generic_unix (all but `network` module - which is also incorrectly +reported as supported on generic_unix, see TODO.md) +- avm_rp2: rp2 only +- avm_stm32: stm32 only +- avm_emscripten: emscripten only +- avm_unix: generic_unix only +""". + +-export([ + update_datafile/2 +]). + +-include_lib("kernel/include/file.hrl"). + +-type scan_opts() :: #{tests => boolean()}. +-type platforms() :: all | [atom()]. +-type since() :: binary() | {unreleased, binary()}. +-type entry() :: {platforms(), since()}. + +-define(ALL_PLATFORMS, [emscripten, esp32, generic_unix, rp2, stm32]). + +-spec build_db_from_list([{atom(), term()}]) -> map(). +build_db_from_list(Data) -> + lists:foldl( + fun({Mod, Funs}, Acc) -> + lists:foldl( + fun({F, A, Platforms, Since0}, A2) -> + maps:put({Mod, F, A}, {Platforms, Since0}, A2) + end, + Acc, + Funs + ) + end, + #{}, + Data + ). + +-doc """ +Update the supported functions database by scanning an AtomVM repository using the provided options. +""". +-spec update_datafile(map(), string()) -> ok | {error, Reason :: term()}. +update_datafile(Opts, OutputFile) -> + Tag = maps:get(tag, Opts, undefined), + Branch = maps:get(branch, Opts, undefined), + Since = derive_since(Tag, Branch), + + ExistingDB = + case file:consult(OutputFile) of + {ok, [Data]} when is_list(Data) -> + io:format("Loading existing data set from ~s\n", [OutputFile]), + build_db_from_list(Data); + {error, enoent} -> + % If no user cache exists, try to load from bundled data for initial values + Datafile = spectrometer_utils:bundled_data_path(), + case file:consult(Datafile) of + {ok, [Data]} when is_list(Data) -> + io:format( + "Loading bundled data set from ~s\n", [Datafile] + ), + build_db_from_list(Data); + {ok, [_]} -> + % Bundled file exists but has invalid structure + #{}; + {error, enoent} -> + io:format( + "No existing data found, starting with empty data set\n" + ), + #{}; + {error, Reason} -> + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end, + + case ExistingDB of + {error, Err} -> + {error, Err}; + _ -> + RepoDir = + case maps:find(atomvm_dir, Opts) of + {ok, Dir} -> + io:format("Using local AtomVM repo: ~s\n", [Dir]), + Dir; + error -> + ClonedDir = + spectrometer_utils:clone_temp_repo( + maps:get(branch, Opts, "main"), + maps:get(tag, Opts, undefined) + ), + case ClonedDir of + {error, _} -> ClonedDir; + _ -> ClonedDir + end + end, + case RepoDir of + {error, Err3} -> + {error, Err3}; + _ -> + ScanOpts = #{tests => maps:get(tests, Opts, true)}, + NewAcc = scan_atomvm_repo(RepoDir, ScanOpts, Since), + + MergedDB = maps:fold( + fun(Key, NewEntry, Acc) -> + case maps:find(Key, Acc) of + {ok, {ExistingPlatforms, ExistingSince}} -> + {MergedPlatforms, MergedSince} = merge_entry( + {ExistingPlatforms, ExistingSince}, + NewEntry + ), + maps:put( + Key, {MergedPlatforms, MergedSince}, Acc + ); + error -> + maps:put(Key, NewEntry, Acc) + end + end, + ExistingDB, + NewAcc + ), + + case maps:find(atomvm_dir, Opts) of + {ok, _} -> + ok; + error -> + TmpDir = RepoDir, + _ = spectrometer_utils:purge_dir(TmpDir), + ok + end, + + case write_db_file(OutputFile, MergedDB) of + ok -> + spectrometer_atomvm:reload_db(), + spectrometer_atomvm:load_db(), + io:format("Done.\n"), + ok; + {error, Err4} -> + io:format("Error writing database file ~p: ~p\n", [ + OutputFile, Err4 + ]), + {error, Err4} + end + end + end. + +-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 +`{Module, Function, Arity}` to `{Platforms, Since}` entries. + +#### Arguments + +- `RepoDir` — Path to the AtomVM repository root +- `Opts` — Options map; `#{tests => false}` skips test file scanning +- `Since` — Version tag (e.g. `<<"v0.7.0">>`) or branch info +""". +-spec scan_atomvm_repo(string(), scan_opts(), since()) -> + #{{atom(), atom(), arity()} => entry()}. +scan_atomvm_repo(RepoDir, Opts, Since) -> + io:format("Scanning AtomVM repo at ~s (since: ~p)\n", [RepoDir, Since]), + LibDir = filename:join(RepoDir, "src/libAtomVM"), + PlatformsDir = filename:join(RepoDir, "src/platforms"), + LibsDir = filename:join(RepoDir, "libs"), + TestsDir = filename:join(RepoDir, "tests"), + + Acc0 = #{}, + Acc1 = + case filelib:is_regular(filename:join(LibDir, "bifs.gperf")) of + true -> + io:format(" Parsing bifs.gperf...\n"), + parse_bifs_gperf( + filename:join(LibDir, "bifs.gperf"), Acc0, all, Since + ); + false -> + io:format(" Skipping bifs.gperf (not found)\n"), + Acc0 + end, + Acc2 = + case filelib:is_regular(filename:join(LibDir, "nifs.gperf")) of + true -> + io:format(" Parsing nifs.gperf...\n"), + parse_nifs_gperf( + filename:join(LibDir, "nifs.gperf"), Acc1, all, Since + ); + false -> + io:format(" Skipping nifs.gperf (not found)\n"), + Acc1 + end, + io:format(" Scanning platform NIFs...\n"), + Acc3 = scan_platform_nifs(PlatformsDir, Acc2, Since), + io:format(" Scanning Erlang library sources...\n"), + Acc4 = scan_erlang_libs(LibsDir, Acc3, 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); + false -> + io:format(" Skipping test file scan (disabled)\n"), + finalize(Acc4) + end. + +-doc false. +%% Finalize scan and log results. +finalize(Acc) -> + io:format( + " Found ~p unique module:function/arity entries\n", + [maps:size(Acc)] + ), + Acc. + +-doc """ +Write a human-readable database file with platform and version information. + +Formats the accumulated scan results into a machine-generated `.data` file +containing `{Module, [{Function, Arity, Platforms, Since}]}` tuples sorted +by module name. +""". +-spec write_db_file(string(), #{{atom(), atom(), arity()} => entry()}) -> + ok | {error, Reason :: term()}. +write_db_file(Path, Acc) -> + ByMod = maps:fold( + fun({M, F, A}, {Platforms, Since}, MAcc) -> + maps:update_with( + M, + fun(L) -> [{F, A, Platforms, Since} | L] end, + [{F, A, Platforms, Since}], + MAcc + ) + end, + #{}, + Acc + ), + SortedMods = lists:sort( + maps:to_list( + maps:map(fun(_K, L) -> lists:usort(L) end, ByMod) + ) + ), + 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", + "%% Since: binary version string like <<\"v0.5.0\">> or {unreleased, <<\"0.7.x\">>}\n", + "\n", + "[\n" + ], + Content = lists:join( + ",\n", + [ + io_lib:format(" {~w, ~w}", [M, FunList]) + || {M, FunList} <- SortedMods + ] + ), + EndLines = ["\n].\n"], + case filelib:ensure_dir(Path) of + ok -> + case file:write_file(Path, Header ++ Content ++ EndLines) of + ok -> + io:format( + "Wrote ~p functions across ~p modules to ~s\n", + [maps:size(Acc), length(SortedMods), Path] + ); + {error, Reason} -> + io:format("Error writing file ~s: ~p\n", [Path, Reason]), + {error, Reason} + end; + {error, Reason} -> + io:format("Error ensuring directory ~s: ~p\n", [Path, Reason]), + {error, Reason} + end. + +-doc """ +Derive the `Since` value from tag and branch options. + +Tags always take precedence over branches. Prerelease suffixes +(`-alpha.#`, `-beta.#`, `-rc.#`) are stripped from tags. +""". +-spec derive_since(string() | undefined, string() | undefined) -> since(). +derive_since(Tag, _Branch) when is_list(Tag), Tag =/= [] -> + normalize_tag(Tag); +derive_since(_Tag, Branch) when is_list(Branch), Branch =/= [] -> + branch_to_since(Branch); +derive_since(undefined, undefined) -> + {unreleased, <<"main">>}. + +-doc false. +%% Normalize a tag string to a binary version string. +%% Strips -alpha.#, -beta.#, -rc.# suffixes. +-spec normalize_tag(string()) -> binary(). +normalize_tag(Tag) -> + Base = re:replace(Tag, "-(alpha|beta|rc)\\.\\d+$", "", [{return, list}]), + list_to_binary(Base). + +-doc false. +%% Convert a branch name to a Since value. +-spec branch_to_since(string()) -> {unreleased, binary()}. +branch_to_since("release-" ++ Version) -> + {unreleased, list_to_binary(Version ++ ".x")}; +branch_to_since("main") -> + {unreleased, <<"main">>}; +branch_to_since(Branch) -> + {unreleased, list_to_binary(Branch)}. + +-doc false. +%% Assign a sort key to a branch name for age comparison. +%% main is newest (tier 3), release branches are tier 2 (ordered by version), +%% unknown branches are tier 1. +-spec branch_sort_key(binary()) -> {1 | 2 | 3, term()}. +branch_sort_key(<<"main">>) -> + {3, <<>>}; +branch_sort_key(<<"release-", Version/binary>>) -> + {2, parse_release_version(Version)}; +branch_sort_key(Branch) -> + case binary:split(Branch, <<".">>, [global]) of + [Major, Minor, <<"x">>] -> + case is_digit_binary(Major) andalso is_digit_binary(Minor) of + true -> + {2, {binary_to_integer(Major), binary_to_integer(Minor)}}; + false -> + {1, Branch} + end; + _ -> + {1, Branch} + end. + +%% Parse a release version string like "0.7" into {0, 7}. +parse_release_version(Version) -> + Parts = binary:split(Version, <<".">>, [global]), + case Parts of + [Major, Minor | _] -> + {binary_to_integer(Major), binary_to_integer(Minor)}; + [Major] -> + {binary_to_integer(Major), 0}; + _ -> + {0, 0} + end. + +%% Check if a binary contains only digit characters. +is_digit_binary(Bin) when is_binary(Bin) -> + case Bin of + <<>> -> + false; + _ -> + lists:all( + fun(C) -> C >= $0 andalso C =< $9 end, binary_to_list(Bin) + ) + end. + +%% Parse a semantic version string like "v0.7.0" or "0.7.0-alpha.1" +%% Returns {ok, {Major, Minor, Patch}} | {error, Reason} +-spec parse_semver(binary() | string()) -> + {ok, {integer(), integer(), integer()}} + | {error, term()}. +parse_semver(Version) when is_binary(Version) -> + parse_semver(binary_to_list(Version)); +parse_semver("v" ++ Rest) -> + parse_semver(Rest); +parse_semver(VersionStr) when is_list(VersionStr) -> + case string:split(VersionStr, "-") of + [Base, _Pre] -> + parse_semver_base(Base); + [Base] -> + parse_semver_base(Base) + end. + +parse_semver_base(Base) -> + case string:split(Base, ".", all) of + [Major, Minor, Patch] -> + try + Maj = list_to_integer(Major), + Min = list_to_integer(Minor), + Pat = list_to_integer(Patch), + {ok, {Maj, Min, Pat}} + catch + _:badarg -> {error, non_integer_version}; + _:Reason -> {error, Reason} + end; + [Major, Minor] -> + try + Maj = list_to_integer(Major), + Min = list_to_integer(Minor), + {ok, {Maj, Min, 0}} + catch + _:badarg -> {error, non_integer_version}; + _:Reason -> {error, Reason} + end; + [Major] -> + try + Maj = list_to_integer(Major), + {ok, {Maj, 0, 0}} + catch + _:badarg -> {error, non_integer_version}; + _:Reason -> {error, Reason} + end; + _ -> + {error, invalid_version_format} + end. + +%% Compare two semantic version binaries. +%% Returns older if First < Second, newer if First > Second, same if equal. +-spec compare_semver(binary(), binary()) -> older | newer | same. +compare_semver(First, Second) -> + case {parse_semver(First), parse_semver(Second)} of + {{ok, V1}, {ok, V2}} -> + compare_semver_versions(V1, V2); + _ -> + %% Fallback to binary comparison if parsing fails + if + First < Second -> older; + First > Second -> newer; + true -> same + end + end. + +compare_semver_versions({M1, Mi1, P1}, {M2, Mi2, P2}) -> + if + M1 > M2 -> newer; + M1 < M2 -> older; + Mi1 > Mi2 -> newer; + Mi1 < Mi2 -> older; + P1 > P2 -> newer; + P1 < P2 -> older; + true -> same + end. + +-doc false. +%% Compare two Since values. Returns true if First is older than Second. +-spec is_older_since(since(), since()) -> boolean(). +is_older_since(First, Second) when is_binary(First), is_binary(Second) -> + case compare_semver(First, Second) of + older -> true; + _ -> false + end; +is_older_since(Tag, {unreleased, _Branch}) when is_binary(Tag) -> + true; +is_older_since({unreleased, _Branch}, Tag) when is_binary(Tag) -> + false; +is_older_since({unreleased, Branch1}, {unreleased, Branch2}) -> + branch_sort_key(Branch1) < branch_sort_key(Branch2). + +-doc """ +Merge two entries following the tag > branch, earliest-wins rules. + +Returns `{MergedPlatforms, MergedSince}` — platforms are combined and +the older `Since` value is kept. +""". +-spec merge_entry(entry(), entry()) -> entry(). +merge_entry({OldPlatforms, OldSince}, {NewPlatforms, NewSince}) -> + MergedPlatforms = merge_platforms_all(OldPlatforms, NewPlatforms), + MergedSince = + case is_older_since(OldSince, NewSince) of + true -> + OldSince; + false -> + case is_older_since(NewSince, OldSince) of + true -> NewSince; + false -> OldSince + end + end, + {MergedPlatforms, MergedSince}. + +-doc false. +%% Merge platforms from two entries. +merge_platforms_all(all, _) -> + all; +merge_platforms_all(_, all) -> + all; +merge_platforms_all(OldList, NewList) when is_list(OldList), is_list(NewList) -> + Merged = lists:umerge(lists:sort(OldList), lists:sort(NewList)), + case Merged of + ?ALL_PLATFORMS -> all; + _ -> Merged + end. + +scan_platform_nifs(PlatformsDir, Acc, Since) -> + case filelib:is_dir(PlatformsDir) of + false -> + io:format(" Platforms dir not found: ~s\n", [PlatformsDir]), + Acc; + true -> + Platforms = discover_platforms(PlatformsDir), + io:format(" Discovered platforms: ~p\n", [Platforms]), + lists:foldl( + fun({PlatName, NifsFile}, A) -> + io:format(" Parsing ~s platform_nifs.c...\n", [PlatName]), + parse_platform_nifs(NifsFile, PlatName, A, Since) + end, + Acc, + Platforms + ) + end. + +discover_platforms(PlatformsDir) -> + case file:list_dir(PlatformsDir) of + {ok, Entries} -> + lists:filtermap( + fun(Entry) -> + PlatDir = filename:join(PlatformsDir, Entry), + case filelib:is_dir(PlatDir) of + true -> + Candidates = [ + filename:join(PlatDir, "platform_nifs.c"), + filename:join([ + PlatDir, "lib", "platform_nifs.c" + ]), + filename:join([ + PlatDir, "src", "lib", "platform_nifs.c" + ]), + filename:join([ + PlatDir, + "components", + "avm_sys", + "platform_nifs.c" + ]) + ], + case find_platform_nifs_file(Candidates) of + {ok, Path} -> + Normalized = spectrometer_utils:normalize_platform_name( + Entry + ), + {true, {Normalized, Path}}; + false -> + false + end; + false -> + false + end + end, + Entries + ); + {error, _} -> + [] + end. + +find_platform_nifs_file([Path | Rest]) -> + case filelib:is_file(Path) of + true -> {ok, Path}; + false -> find_platform_nifs_file(Rest) + end; +find_platform_nifs_file([]) -> + false. + +%% Generic file scanner that extracts function entries using a regex and +%% accumulates them with platform/version metadata. +%% Pattern should capture groups that the KeyFun can transform into a key. +%% EntryFun receives captured groups and returns the value to store. +-doc false. +-spec parse_file_entries( + string(), + iodata(), + fun(([string()]) -> term()), + platforms(), + since(), + map() +) -> map(). +parse_file_entries(File, Pattern, KeyFun, Platforms, Since, Acc) -> + {ok, Bin} = file:read_file(File), + Lines = string:split(binary_to_list(Bin), "\n", all), + lists:foldl( + fun(Line, A) -> + case re:run(Line, Pattern, [{capture, all_but_first, list}]) of + {match, Groups} -> + Key = KeyFun(Groups), + maps:put(Key, {Platforms, Since}, A); + nomatch -> + A + end + end, + Acc, + Lines + ). + +%% Generic file scanner for parsing with global regex (finds all matches at once) +%% and merging into accumulator with custom merger function. +-doc false. +-spec parse_file_global( + string(), + iodata(), + fun(([string()], map()) -> map()), + map() +) -> map(). +parse_file_global(File, Pattern, MergeFun, Acc) -> + {ok, Bin} = file:read_file(File), + Content = binary_to_list(Bin), + case re:run(Content, Pattern, [{capture, all_but_first, list}, global]) of + {match, Matches} -> + lists:foldl(MergeFun, Acc, Matches); + nomatch -> + Acc + end. + +parse_platform_nifs(File, Platform, Acc, Since) -> + MergeFun = fun([ModStr, FunStr, ArityStr], A) -> + Arity = list_to_integer(ArityStr), + Key = { + spectrometer_utils:atom_from_string(ModStr), + spectrometer_utils:atom_from_string(FunStr), + Arity + }, + maps:update_with( + Key, + fun({ExistingPlatforms, ExistingSince}) -> + { + merge_platforms(ExistingPlatforms, Platform), + merge_since(ExistingSince, Since) + } + end, + {[Platform], Since}, + A + ) + end, + parse_file_global( + File, + "strcmp\\s*\\(\\s*\"([a-z_][a-z0-9_]*):([A-Za-z_][A-Za-z0-9_]*)/(\\d+)\"", + MergeFun, + Acc + ). + +%% Merge Since values following the tag > branch, earliest-wins rules. +merge_since(Old, New) when is_binary(Old), is_binary(New) -> + %% Both are tags - keep the older (semantically smaller) one + case compare_semver(Old, New) of + older -> Old; + _ -> New + end; +merge_since({unreleased, _OldBranch}, New) when is_binary(New) -> + %% Tag replaces unreleased branch + New; +merge_since(Old, {unreleased, _NewBranch}) when is_binary(Old) -> + %% Existing tag is kept (tag wins over branch) + Old; +merge_since({unreleased, OldBranch}, {unreleased, NewBranch}) -> + %% Both are unreleased - keep the older (smaller sort key) one + case branch_sort_key(OldBranch) < branch_sort_key(NewBranch) of + true -> {unreleased, OldBranch}; + false -> {unreleased, NewBranch} + end; +merge_since(Old, _New) -> + %% Fallback - keep existing + Old. + +merge_platforms(all, _NewPlatform) -> + all; +merge_platforms(Existing, NewPlatform) when is_list(Existing) -> + case lists:member(NewPlatform, Existing) of + true -> + Existing; + false -> + Platforms = lists:sort([NewPlatform | Existing]), + case Platforms of + ?ALL_PLATFORMS -> all; + _ -> Platforms + end + end; +merge_platforms(Existing, NewPlatform) -> + Platforms = lists:sort([NewPlatform | Existing]), + case Platforms of + ?ALL_PLATFORMS -> all; + _ -> Platforms + end. + +parse_bifs_gperf(File, Acc, Platforms, Since) -> + KeyFun = fun([Fun, ArityStr]) -> + Arity = list_to_integer(ArityStr), + {erlang, spectrometer_utils:atom_from_string(Fun), Arity} + end, + parse_file_entries( + File, + "^\\s*erlang:([A-Za-z0-9_+'/-]+|[^/,\\s]+)/(\\d+)", + KeyFun, + Platforms, + Since, + Acc + ). + +parse_nifs_gperf(File, Acc, Platforms, Since) -> + KeyFun = fun([Mod, Fun, ArityStr]) -> + Arity = list_to_integer(ArityStr), + { + spectrometer_utils:atom_from_string(Mod), + spectrometer_utils:atom_from_string(Fun), + Arity + } + end, + parse_file_entries( + File, + "\\s*?\"?([a-z_][a-z0-9_]*):([A-Za-z_][A-Za-z0-9_]*)/(\\d+)\"?", + KeyFun, + Platforms, + Since, + Acc + ). + +scan_erlang_libs(LibsDir, Acc, Since) -> + case filelib:is_dir(LibsDir) of + false -> + io:format(" libs dir not found: ~s\n", [LibsDir]), + Acc; + true -> + Acc1 = scan_lib_group( + LibsDir, all_platform_libs(), all, Acc, Since + ), + Acc2 = scan_lib_group( + LibsDir, hal_platform_libs(), [esp32, stm32, rp2], Acc1, Since + ), + Acc3 = scan_lib_group( + LibsDir, esp32_only_libs(), [esp32], Acc2, Since + ), + Acc4 = scan_lib_group( + LibsDir, network_libs(), [generic_unix, esp32, rp2], Acc3, Since + ), + Acc5 = scan_lib_group(LibsDir, rp2_only_libs(), [rp2], Acc4, Since), + Acc6 = scan_lib_group( + LibsDir, stm32_only_libs(), [stm32], Acc5, Since + ), + Acc7 = scan_lib_group( + LibsDir, emscripten_only_libs(), [emscripten], Acc6, Since + ), + scan_lib_group( + LibsDir, generic_unix_only_libs(), [generic_unix], Acc7, Since + ) + end. + +all_platform_libs() -> + ["alisp", "estdlib", "etest", "jit", "gleam_avm", "eavmlib"]. + +hal_platform_libs() -> + %% These are _hal.erl files within eavmlib + + %% Handled specially in scan_lib_group + []. + +esp32_only_libs() -> + ["avm_esp32", "esp32boot", "esp32devmode"]. + +network_libs() -> + ["avm_network"]. + +rp2_only_libs() -> + ["avm_rp2"]. + +stm32_only_libs() -> + ["avm_stm32"]. + +emscripten_only_libs() -> + ["avm_emscripten"]. + +generic_unix_only_libs() -> + ["avm_unix"]. + +scan_lib_group(_LibsDir, [], _Platforms, Acc, _Since) -> + Acc; +scan_lib_group(LibsDir, LibNames, Platforms, Acc, Since) -> + lists:foldl( + fun(LibName, A) -> + LibSrcDir = filename:join([LibsDir, LibName, "src"]), + case filelib:is_dir(LibSrcDir) of + true -> + ErlFiles = find_erl_files(LibSrcDir), + io:format( + " Scanning ~s (~p files, platforms: ~p)\n", + [LibName, length(ErlFiles), Platforms] + ), + lists:foldl( + fun(F, A2) -> + parse_exports( + F, Platforms, Since, A2 + ) + end, + A, + ErlFiles + ); + false -> + A + end + end, + Acc, + LibNames + ). + +parse_exports(File, Platforms, Since, Acc) -> + {ok, Bin} = file:read_file(File), + Lines = string:split(binary_to_list(Bin), "\n", all), + ModName = find_module_name(Lines), + case ModName of + undefined -> + Acc; + Mod -> + Exports = find_exports(Lines), + BaseName = filename:basename(File, ".erl"), + %% Check if this is a _hal.erl file + BaseLen = string:length(BaseName), + ActualPlatforms = + case + (BaseLen >= 4) andalso + string:equal( + string:slice(BaseName, BaseLen - 4), "_hal" + ) + of + true -> + %% HAL files are only for esp32, stm32, rp2 + case Platforms of + all -> [esp32, stm32, rp2]; + _ -> Platforms + end; + false -> + Platforms + end, + lists:foldl( + fun({F, A}, A2) -> + maps:put({Mod, F, A}, {ActualPlatforms, Since}, A2) + end, + Acc, + Exports + ) + end. + +find_module_name(Lines) -> + find_first_match( + "-module\\s*\\(\\s*([a-z_][a-z0-9_]*)\\s*\\)\\s*\\.", Lines + ). + +find_first_match(Regex, Lines) -> + find_first_match(Regex, Lines, undefined). + +find_first_match(_Regex, [], Default) -> + Default; +find_first_match(Regex, [Line | Rest], Default) -> + case re:run(Line, Regex, [{capture, all_but_first, list}]) of + {match, [Name]} -> spectrometer_utils:atom_from_string(Name); + _ -> find_first_match(Regex, 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*\\(([^)]+)\\)", [ + global, {capture, all_but_first, list} + ]) + of + {match, Matches} -> + lists:flatmap( + fun([Content]) -> + parse_export_list(Content) + end, + Matches + ); + nomatch -> + [] + end. + +parse_export_list(Content) -> + Trimmed = string:trim(Content), + %% Remove surrounding brackets if present + Inner = + case Trimmed of + [$[ | Rest] -> + case lists:last(Rest) of + $] -> + lists:sublist(Rest, 1, length(Rest) - 1); + _ -> + Trimmed + end; + _ -> + Trimmed + end, + Tokens = string:split(Inner, ",", all), + lists:filtermap( + fun(Token) -> + case + re:run( + string:trim(Token), "^([a-z_][a-z0-9_]*)\\s*/\\s*(\\d+)$", [ + {capture, all_but_first, list} + ] + ) + of + {match, [Fun, ArityStr]} -> + {true, { + spectrometer_utils:atom_from_string(Fun), + list_to_integer(ArityStr) + }}; + _ -> + false + end + end, + Tokens + ). + +scan_test_files(TestsDir, Acc, Since) -> + case filelib:is_dir(TestsDir) of + false -> + io:format(" tests dir not found: ~s\n", [TestsDir]), + Acc; + true -> + ErlTestsDir = filename:join(TestsDir, "erlang_tests"), + Acc1 = scan_calls_dir(ErlTestsDir, "erlang_tests", Acc, Since), + EstdlibTestsDir = filename:join([TestsDir, "libs", "estdlib"]), + Acc2 = scan_calls_dir( + EstdlibTestsDir, "tests/libs/estdlib", Acc1, Since + ), + EavmlibTestsDir = filename:join([TestsDir, "libs", "eavmlib"]), + scan_calls_dir( + EavmlibTestsDir, "tests/libs/eavmlib", Acc2, Since + ) + end. + +scan_calls_dir(Dir, Label, Acc, Since) -> + case filelib:is_dir(Dir) of + true -> + Files = find_erl_files(Dir), + io:format(" Found ~p .erl files in ~s\n", [length(Files), Label]), + scan_calls(Files, Acc, Since); + false -> + Acc + end. + +scan_calls(Files, Acc, Since) -> + OTPMods = spectrometer_otp:modules_list(), + OTPAtoms = [spectrometer_utils:atom_from_string(Mod) || Mod <- OTPMods], + OTPSet = sets:from_list(OTPAtoms), + lists:foldl( + fun(File, A) -> + case spectrometer_scanner:parse_calls(File) of + {ok, ModName, Calls} -> + % Filter to OTP calls and exclude self-calls + Filtered = maps:filter( + fun({Mod, _Fun, _Arity}, _Count) -> + sets:is_element(Mod, OTPSet) andalso Mod =/= ModName + end, + Calls + ), + % Convert to accumulator format with all platforms + maps:fold( + fun({Mod, Fun, Arity}, _Count, Acc2) -> + Key = {Mod, Fun, Arity}, + case maps:is_key(Key, Acc2) of + true -> + case maps:get(Key, Acc2) of + {all, _} -> Acc2; + _ -> maps:put(Key, {all, Since}, Acc2) + end; + false -> + maps:put(Key, {all, Since}, Acc2) + end + end, + A, + Filtered + ); + {error, _} -> + A + end + end, + Acc, + Files + ). + +find_erl_files(Dir) -> + find_erl_files(Dir, []). + +find_erl_files(Dir, Acc) -> + case file:list_dir(Dir) of + {ok, Entries} -> + lists:foldl( + fun(Entry, A) -> + Path = filename:join(Dir, Entry), + case file:read_link_info(Path) of + {ok, #file_info{type = directory}} -> + case Entry of + %% skip _build, .git etc + "_" ++ _ -> A; + "." ++ _ -> A; + _ -> find_erl_files(Path, A) + end; + {ok, #file_info{type = regular}} -> + case filename:extension(Entry) of + ".erl" -> [Path | A]; + _ -> A + end; + _ -> + A + end + end, + Acc, + Entries + ); + {error, _} -> + Acc + end. diff --git a/src/spectrometer_utils.erl b/src/spectrometer_utils.erl new file mode 100644 index 0000000..ea5c977 --- /dev/null +++ b/src/spectrometer_utils.erl @@ -0,0 +1,402 @@ +%% +%% 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_utils). + +-moduledoc """ +Utility functions shared across the application. + +This module provides common infrastructure helpers used by other modules: +temporary directory creation under the user cache directory, recursive +directory removal, and GitHub URL normalization for deduplication. +""". + +-export([ + atom_from_string/1, + clone_temp_repo/2, + bundled_data_path/0, + make_temp_dir/1, + normalize_github_url/1, + normalize_platform_name/1, + purge_dir/1, + run_git_command/2, + start_applications/0, + user_cache_path/0, + user_db_file/0, + version/0 +]). + +-type platform() :: emscripten | esp32 | generic_unix | rp2 | stm32. + +-doc """ +Convert a string to an atom, using list_to_existing_atom if possible for safety. +If the string does not correspond to an existing atom, it will be created with list_to_atom. +""". +-spec atom_from_string(string()) -> atom(). +atom_from_string(Str) -> + try + list_to_existing_atom(Str) + catch + error:badarg -> list_to_atom(Str) + end. + +-doc """ +Return the path to the bundled human-readable data file. + +The returned path points to `priv/supported_functions.data` within the +application's installation directory. Works for both normal OTP application +loads and escript builds. +""". +-spec bundled_data_path() -> string(). +bundled_data_path() -> + bundled_db_file(). + +-doc """ +Return the path to the user cache directory. +The returned path is platform-appropriate: +- On Unix-like systems: `~/.cache/spectrometer` +- On macOS: `~/Library/Caches/spectrometer` +- On Windows: `%APPDATA%\\spectrometer` or `~\spectrometer` if `APPDATA` is unset +""". +-spec user_cache_path() -> file:filename_all(). +user_cache_path() -> + case application:get_env(spectrometer, cache_dir) of + {ok, Dir} -> + case filelib:is_dir(Dir) of + true -> + Dir; + false -> + ok = filelib:ensure_path(Dir), + Dir + end; + undefined -> + CachePath = filename:basedir(user_cache, "spectrometer"), + ok = filelib:ensure_path(CachePath), + application:set_env(spectrometer, cache_dir, CachePath), + CachePath + end. + +-doc """ +Return the path to the cached data file. + +The returned path points to `${user_cache_path}/supported_functions.data` if it +exists, otherwise it points to `priv/supported_functions.data` within the +application's installation directory. Works for both normal OTP application +loads and escript builds. +""". +-spec user_db_file() -> string(). +user_db_file() -> + filename:join(user_cache_path(), "supported_functions.data"). + +-doc false. +%% Find the bundled human-readable data file. +%% Tries code:priv_dir first, then falls back to paths relative to the escript. +bundled_db_file() -> + case code:priv_dir(spectrometer) of + Priv when is_list(Priv) -> + Candidate = filename:join(Priv, "supported_functions.data"), + case filelib:is_regular(Candidate) of + true -> Candidate; + false -> try_script_relative() + end; + _ -> + try_script_relative() + end. + +-doc false. +%% For escript builds: resolve path relative to the escript binary location. +%% Tries multiple candidate paths (rebar3 build, installed, source tree). +try_script_relative() -> + ScriptDir = + case filename:dirname(escript:script_name()) of + D when is_list(D) -> D; + _ -> + case code:which(?MODULE) of + BeamPath when is_list(BeamPath) -> + BeamDir = filename:dirname(BeamPath), + case filename:basename(BeamDir) of + "ebin" -> filename:dirname(BeamDir); + _ -> BeamDir + end; + _ -> + "." + end + end, + Candidates = [ + user_db_file(), + filename:join([ + ScriptDir, + "..", + "lib", + "spectrometer", + "priv", + "supported_functions.data" + ]), + filename:join([ScriptDir, "..", "priv", "supported_functions.data"]), + filename:join(ScriptDir, "priv/supported_functions.data"), + filename:join([ + ScriptDir, "..", "..", "priv", "supported_functions.data" + ]), + filename:join([ + ScriptDir, "..", "..", "..", "priv", "supported_functions.data" + ]), + "priv/supported_functions.data" + ], + find_first_file(Candidates). + +-doc false. +%% Find the first existing file from a list of candidate paths. +find_first_file([Path | Rest]) -> + case filelib:is_regular(Path) of + true -> Path; + false -> find_first_file(Rest) + end; +find_first_file([]) -> + "priv/supported_functions.data". + +-doc """ +Create a temporary directory. + +The directory name is formed by concatenating the given `Prefix` with a unique +integer suffix. The directory will be created in a sub-directory of +"spectrometer" the users temp directory, typically "/tmp". Given the prefix +"test_cache_" the result would be similar to: +>`/tmp/spectrometer/test_cache_454279` +""". +make_temp_dir(Prefix) -> + Rand = integer_to_list(erlang:unique_integer([positive])), + Dir = filename:join([system_temp_dir(), "spectrometer", Prefix ++ Rand]), + ok = filelib:ensure_path(Dir), + Dir. + +-doc """ +Recursively remove a directory and all its contents. + +Uses `file:del_dir_r/1` for portable cross-platform directory removal. +Returns `ok` on success or `{error, Reason}` on failure. +""". +-spec purge_dir(file:filename_all()) -> ok | {error, term()}. +purge_dir(Dir) -> + case file:del_dir_r(Dir) of + ok -> ok; + {error, Reason} -> {error, Reason} + end. + +-doc "Run a git command safely using open_port with spawn_executable, with environment vars". +-spec run_git_command([string()], [{string(), string()}]) -> + {ok, string()} | {error, term()}. +run_git_command(Args, EnvVars) -> + Cmd = "git", + case find_executable(Cmd) of + {ok, ExecPath} -> + PortOpts = [{args, Args}, exit_status, {line, 16384}], + PortOpts1 = + case EnvVars of + [] -> PortOpts; + _ -> [{env, EnvVars} | PortOpts] + end, + try + Port = open_port({spawn_executable, ExecPath}, PortOpts1), + gather_git_output(Port, []) + catch + error:Reason -> + {error, Reason} + end; + {error, not_found} -> + {error, {executable_not_found, Cmd}} + end. + +-doc "Find an executable in PATH or return error if not found". +-spec find_executable(string()) -> {ok, string()} | {error, not_found}. +find_executable(Cmd) -> + case os:find_executable(Cmd) of + false -> {error, not_found}; + Path -> {ok, Path} + end. + +-doc "Gather output from a port until it closes for git commands". +-spec gather_git_output(port(), [string()]) -> {ok, string()} | {error, term()}. +gather_git_output(Port, Acc) -> + receive + {Port, {exit_status, 0}} -> + {ok, lists:flatten(lists:reverse(Acc))}; + {Port, {exit_status, Status}} -> + {error, {exit_status, Status, lists:flatten(lists:reverse(Acc))}}; + {Port, {data, {eol, Line}}} -> + gather_git_output(Port, [Line ++ "\n" | Acc]); + {Port, {data, {noeol, Line}}} -> + gather_git_output(Port, [Line | Acc]) + after 120000 -> + port_close(Port), + drain_port_messages(Port), + {error, timeout} + end. +%% Drain any pending messages for a closed port to avoid mailbox pollution. +drain_port_messages(Port) -> + receive + {Port, _} -> drain_port_messages(Port) + after 0 -> + ok + end. + +-doc """ +Normalize a GitHub URL for deduplication. + +Accepts bare repository paths (e.g., `atomvm/AtomVM`), full URLs with or without protocol. +Strips the protocol (`https://` or `http://`), trailing slashes, `.git` +suffix, and converts to lowercase. This ensures consistent comparison +of GitHub repository URLs across different formats. + +#### Example + +```erlang +1> spectrometer_utils:normalize_github_url("https://github.com/atomvm/AtomVM.git"). +"https://github.com/atomvm/atomvm.git" +2> spectrometer_utils:normalize_github_url("http://github.com/atomvm/AtomVM"). +"https://github.com/atomvm/atomvm.git" +3> spectrometer_utils:normalize_github_url("atomvm/AtomVM"). +"https://github.com/atomvm/atomvm.git" +``` +""". +normalize_github_url(Url) -> + Url1 = string:lowercase(Url), + Url2 = string:trim(Url1), + Url3 = string:trim(Url2, trailing, "/"), + Url4 = re:replace(Url3, "\\.git$", "", [{return, list}]), + Url5 = re:replace(Url4, "^https?://", "", [{return, list}]), + Url6 = re:replace(Url5, "^github.com/", "", [{return, list}]), + "https://github.com/" ++ Url6 ++ ".git". + +-doc """ +Normalize a platform name for consistent comparison. Removes whitespace and converts to lowercase. +Returns supported platform name atoms, or `{error, badarg}` for unsupported platforms. +""". +-spec normalize_platform_name(string()) -> platform() | {error, badarg}. +normalize_platform_name(Name) -> + NameStr = unicode:characters_to_list(Name), + normalized_name(string:lowercase(string:trim(NameStr))). + +-spec normalized_name(string()) -> platform() | {error, badarg}. +normalized_name("rp2") -> rp2; +normalized_name("rp2040") -> rp2; +normalized_name("esp32") -> esp32; +normalized_name("stm32") -> stm32; +normalized_name("emscripten") -> emscripten; +normalized_name("generic_unix") -> generic_unix; +normalized_name("genericunix") -> generic_unix; +normalized_name(_) -> {error, badarg}. + +-doc """ +Clone the AtomVM GitHub repository to a temporary directory. +The repository is cloned with `--depth 1` for efficiency. The specified branch is checked out, and +optionally a specific tag can be checked out as well. The function returns the path to the cloned +repository. Errors during cloning or checkout are printed to the console, and the function halts +with an error code if cloning fails. +""". +-spec clone_temp_repo(string(), string() | undefined) -> + string() | {error, Reason :: term()}. +clone_temp_repo(Branch, Tag) -> + TmpDir = spectrometer_utils:make_temp_dir("avm_update_"), + Url = "https://github.com/atomvm/AtomVM", + io:format("Cloning ~s (branch ~s) to ~s...\n", [Url, Branch, TmpDir]), + CloneResult = run_git_command( + [ + "clone", "--quiet", "--depth", "1", "-b", Branch, Url, TmpDir + ], + [{"GIT_TERMINAL_PROMPT", "0"}] + ), + case CloneResult of + {ok, _} -> + case Tag of + undefined -> + TmpDir; + TagStr when is_list(TagStr) -> + io:format("Checking out tag ~s...\n", [TagStr]), + _ = run_git_command( + ["-C", TmpDir, "fetch", "--tags", "--quiet"], + [{"GIT_TERMINAL_PROMPT", "0"}] + ), + CheckoutResult = run_git_command( + ["-C", TmpDir, "checkout", "--quiet", TagStr], + [{"GIT_TERMINAL_PROMPT", "0"}] + ), + case CheckoutResult of + {ok, _} -> + TmpDir; + {error, Reason} when + is_tuple(Reason); is_atom(Reason) + -> + _ = purge_dir(TmpDir), + {error, {checkout_failed, TagStr, Reason}}; + Error -> + _ = purge_dir(TmpDir), + Error + end + end; + {error, Reason} when is_tuple(Reason); is_atom(Reason) -> + io:format("Error: Could not clone ~s: ~p\n", [Url, Reason]), + _ = purge_dir(TmpDir), + {error, Reason}; + Error -> + io:format("Error: Could not clone ~s: ~p\n", [Url, Error]), + _ = purge_dir(TmpDir), + Error + end. + +-spec version() -> string() | {error, Reason :: term()}. +version() -> + case application:ensure_all_started(spectrometer) of + {ok, _} -> + case application:get_key(spectrometer, vsn) of + {ok, Vsn} -> Vsn; + undefined -> {error, version_not_found} + end; + {error, Reason} -> + {error, Reason} + end. + +-spec start_applications() -> ok | {error, Reason :: term()}. +start_applications() -> + try + case + application:ensure_all_started([ + inets, ssl, compiler, syntax_tools, spectrometer + ]) + of + {ok, _} -> ok; + {error, R0} -> error(R0) + end, + case + httpc:set_options([{max_sessions, 8}, {max_keep_alive_length, 16}]) + of + ok -> ok; + {error, R1} -> error(R1) + end + catch + error:Reason -> + {error, Reason} + end. + +%% Get a system temp directory (cross-platform) +system_temp_dir() -> + case os:getenv("TEMPDIR") of + false -> + os:getenv("TEMP", os_temp_dir()); + Temp -> + Temp + end. + +os_temp_dir() -> + case os:type() of + {win32, _} -> + "C:/Windows/Temp"; + _ -> + "/tmp" + end. diff --git a/test/atomvm_spectrometer_tests.erl b/test/atomvm_spectrometer_tests.erl new file mode 100644 index 0000000..d3bc744 --- /dev/null +++ b/test/atomvm_spectrometer_tests.erl @@ -0,0 +1,866 @@ +%% +%% 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(atomvm_spectrometer_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% maybe_halt/1 tests - Test mode exit handling +%% ============================================================================= + +maybe_halt_zero_test_() -> + {"maybe_halt(0) returns ok in test mode", fun() -> + ?assertEqual(ok, atomvm_spectrometer:maybe_halt(0)) + end}. + +maybe_halt_nonzero_test_() -> + {"maybe_halt(Code) returns error tuple in test mode", fun() -> + ?assertEqual({error, {halt, 1}}, atomvm_spectrometer:maybe_halt(1)), + ?assertEqual({error, {halt, 2}}, atomvm_spectrometer:maybe_halt(2)), + ?assertEqual({error, {halt, 127}}, atomvm_spectrometer:maybe_halt(127)) + end}. + +%% ============================================================================= +%% parse_args/1 tests - Top-level argument parsing +%% ============================================================================= + +parse_args_empty_test_() -> + {"returns help for empty args", fun() -> + Result = atomvm_spectrometer:parse_args([]), + ?assertEqual(help, Result) + end}. + +parse_args_help_flags_test_() -> + {"recognizes --help and -h flags", fun() -> + ?assertEqual(help, atomvm_spectrometer:parse_args(["--help"])), + ?assertEqual(help, atomvm_spectrometer:parse_args(["-h"])), + ?assertEqual(help, atomvm_spectrometer:parse_args(["--help", "audit"])), + ?assertEqual(help, atomvm_spectrometer:parse_args(["-h", "ecosystem"])), + %% Test --help for each command + ?assertEqual( + {help, supported}, + atomvm_spectrometer:parse_args(["supported", "--help"]) + ), + ?assertEqual( + {help, examine}, + atomvm_spectrometer:parse_args(["examine", "--help"]) + ), + ?assertEqual( + {help, filter}, atomvm_spectrometer:parse_args(["filter", "--help"]) + ), + ?assertEqual( + {help, update}, atomvm_spectrometer:parse_args(["update", "--help"]) + ), + ?assertEqual( + {help, query}, atomvm_spectrometer:parse_args(["query", "--help"]) + ) + end}. + +parse_args_help_command_test_() -> + {"handles help subcommands", fun() -> + ?assertEqual(help, atomvm_spectrometer:parse_args(["help"])), + ?assertEqual( + {help, audit}, atomvm_spectrometer:parse_args(["help", "audit"]) + ), + ?assertEqual( + {help, examine}, atomvm_spectrometer:parse_args(["help", "examine"]) + ), + ?assertEqual( + {help, ecosystem}, + atomvm_spectrometer:parse_args(["help", "ecosystem"]) + ), + ?assertEqual( + {help, supported}, + atomvm_spectrometer:parse_args(["help", "supported"]) + ), + ?assertEqual( + {help, filter}, atomvm_spectrometer:parse_args(["help", "filter"]) + ), + ?assertEqual( + {help, update}, atomvm_spectrometer:parse_args(["help", "update"]) + ), + ?assertEqual( + {help, query}, atomvm_spectrometer:parse_args(["help", "query"]) + ) + end}. + +parse_args_command_help_flags_test_() -> + {"handles COMMAND -h and COMMAND --help", fun() -> + ?assertEqual( + {help, audit}, atomvm_spectrometer:parse_args(["audit", "-h"]) + ), + ?assertEqual( + {help, audit}, atomvm_spectrometer:parse_args(["audit", "--help"]) + ), + ?assertEqual( + {help, ecosystem}, + atomvm_spectrometer:parse_args(["ecosystem", "-h"]) + ), + ?assertEqual( + {help, ecosystem}, + atomvm_spectrometer:parse_args(["ecosystem", "--help"]) + ), + ?assertEqual( + {help, supported}, + atomvm_spectrometer:parse_args(["supported", "-h"]) + ), + ?assertEqual( + {help, supported}, + atomvm_spectrometer:parse_args(["supported", "--help"]) + ), + ?assertEqual( + {help, examine}, atomvm_spectrometer:parse_args(["examine", "-h"]) + ), + ?assertEqual( + {help, examine}, + atomvm_spectrometer:parse_args(["examine", "--help"]) + ), + ?assertEqual( + {help, filter}, atomvm_spectrometer:parse_args(["filter", "-h"]) + ), + ?assertEqual( + {help, update}, atomvm_spectrometer:parse_args(["update", "-h"]) + ), + ?assertEqual( + {help, query}, atomvm_spectrometer:parse_args(["query", "-h"]) + ) + end}. + +parse_args_unknown_help_test_() -> + {"returns error for unknown help command", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args(["help", "unknown"]), + ?assert(string:str(Msg, "Unknown command") > 0) + end}. + +parse_args_unknown_command_test_() -> + {"returns error for unknown command", fun() -> + %% Unknown commands at top level cause function_clause (intentional) + ?assertEqual( + {error, "Unsupported command foobar"}, + atomvm_spectrometer:parse_args(["foobar"]) + ) + end}. + +%% ============================================================================= +%% parse_scan_args/2 tests +%% ============================================================================= + +parse_scan_args_github_test_() -> + {"parses --github URL", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", "--github", "https://github.com/user/repo" + ]), + ?assertEqual( + {github_url, "https://github.com/user/repo"}, maps:get(target, Opts) + ) + end}. + +parse_scan_args_hex_test_() -> + {"parses --hex package", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", "--hex", "jsx" + ]), + ?assertEqual({hex, "jsx"}, maps:get(target, Opts)) + end}. + +parse_scan_args_hex_version_test_() -> + {"parses --hex with --version", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", "--hex", "cowboy", "--version", "3.1.0" + ]), + ?assertEqual({hex, "cowboy", "3.1.0"}, maps:get(target, Opts)) + end}. + +parse_scan_args_version_hex_test_() -> + {"parses --version before --hex folds version into target", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", "--version", "3.1.0", "--hex", "cowboy" + ]), + ?assertEqual({hex, "cowboy", "3.1.0"}, maps:get(target, Opts)), + % Ensure version key is removed from final opts + ?assertNot(maps:is_key(version, Opts)) + end}. + +parse_scan_args_dir_test_() -> + {"parses --dir path", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", "--dir", "/path/to/project" + ]), + ?assertEqual({local_dir, "/path/to/project"}, maps:get(target, Opts)) + end}. + +parse_scan_args_output_test_() -> + {"parses -o and --output", fun() -> + {command, audit, Opts1} = atomvm_spectrometer:parse_args([ + "audit", + "--github", + "https://github.com/user/repo", + "-o", + "report.csv" + ]), + ?assertEqual("report.csv", maps:get(output, Opts1)), + {command, audit, Opts2} = atomvm_spectrometer:parse_args([ + "audit", + "--github", + "https://github.com/user/repo", + "--output", + "report.csv" + ]), + ?assertEqual("report.csv", maps:get(output, Opts2)) + end}. + +parse_scan_args_top_test_() -> + {"parses --top N", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", "--github", "https://github.com/user/repo", "--top", "20" + ]), + ?assertEqual(20, maps:get(top, Opts)) + end}. + +parse_scan_args_min_count_test_() -> + {"parses --min-count N", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", + "--github", + "https://github.com/user/repo", + "--min-count", + "5" + ]), + ?assertEqual(5, maps:get(min_count, Opts)) + end}. + +parse_scan_args_missing_target_test_() -> + {"returns error for missing target", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args(["audit"]), + ?assert(string:str(Msg, "No target") > 0) + end}. + +parse_scan_args_invalid_top_test_() -> + {"returns error for invalid --top value", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args([ + "audit", "--github", "https://github.com/user/repo", "--top", "abc" + ]), + ?assert(string:str(Msg, "Invalid") > 0) + end}. + +parse_scan_args_invalid_min_count_test_() -> + {"returns error for invalid --min-count value", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args([ + "audit", + "--github", + "https://github.com/user/repo", + "--min-count", + "-1" + ]), + ?assert(string:str(Msg, "Invalid") > 0) + end}. + +parse_scan_args_cache_long_test_() -> + {"parses audit --cache dir", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", + "--github", + "https://github.com/user/repo", + "--cache", + "/tmp/custom" + ]), + ?assertEqual("/tmp/custom", maps:get(cache_dir, Opts)) + end}. + +parse_scan_args_cache_short_test_() -> + {"parses audit -c dir", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", + "--github", + "https://github.com/user/repo", + "-c", + "/tmp/custom" + ]), + ?assertEqual("/tmp/custom", maps:get(cache_dir, Opts)) + end}. + +parse_supported_args_cache_long_test_() -> + {"parses supported --cache dir", fun() -> + {command, supported, Opts} = atomvm_spectrometer:parse_args([ + "supported", "--cache", "/tmp/custom" + ]), + ?assertEqual("/tmp/custom", maps:get(cache_dir, Opts)) + end}. + +parse_update_args_cache_long_test_() -> + {"parses update --cache dir", fun() -> + {command, update, Opts} = atomvm_spectrometer:parse_args([ + "update", "--cache", "/tmp/custom" + ]), + ?assertEqual("/tmp/custom", maps:get(cache_dir, Opts)) + end}. + +parse_filter_avm_test_() -> + {"parses filter --avm", fun() -> + {command, filter, Opts} = atomvm_spectrometer:parse_args([ + "filter", "--avm" + ]), + ?assertEqual(true, maps:get(avm, Opts)) + end}. + +parse_query_cache_long_test_() -> + {"parses query --cache dir", fun() -> + {command, query, Opts} = atomvm_spectrometer:parse_args([ + "query", "--cache", "/tmp/custom", "lists:map" + ]), + ?assertEqual("/tmp/custom", maps:get(cache_dir, Opts)) + end}. + +parse_scan_args_unknown_option_test_() -> + {"returns error for unknown option", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args([ + "audit", "--github", "https://github.com/user/repo", "--unknown" + ]), + ?assert(string:str(Msg, "Unknown option") > 0) + end}. + +parse_scan_args_multi_test_() -> + {"parses --multi file", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", "--multi", "targets.txt" + ]), + ?assertEqual("targets.txt", maps:get(multi_file, Opts)) + end}. + +parse_scan_args_version_standalone_test_() -> + {"parses --version without --hex", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", + "--github", + "https://github.com/user/repo", + "--version", + "1.0.0" + ]), + ?assertEqual("1.0.0", maps:get(version, Opts)) + end}. + +parse_scan_args_output_flag_test_() -> + {"parses --output flag", fun() -> + {command, audit, Opts} = atomvm_spectrometer:parse_args([ + "audit", + "--github", + "https://github.com/user/repo", + "--output", + "report.csv" + ]), + ?assertEqual("report.csv", maps:get(output, Opts)) + end}. + +%% ============================================================================= +%% parse_ecosystem_args/2 tests +%% ============================================================================= + +parse_ecosystem_args_defaults_test_() -> + {"uses default options", fun() -> + {command, ecosystem, Opts} = atomvm_spectrometer:parse_args([ + "ecosystem" + ]), + ?assertEqual(4, maps:get(workers, Opts)), + ?assertEqual(true, maps:get(github, Opts)), + ?assertEqual(true, maps:get(hex, Opts)), + ?assertEqual(infinity, maps:get(limit, Opts)), + ?assertEqual(false, maps:get(resume, Opts)) + end}. + +parse_ecosystem_args_workers_test_() -> + {"parses --workers N", fun() -> + {command, ecosystem, Opts} = atomvm_spectrometer:parse_args([ + "ecosystem", "--workers", "8" + ]), + ?assertEqual(8, maps:get(workers, Opts)) + end}. + +parse_ecosystem_args_source_test_() -> + {"parses --github-only and --hex-only", fun() -> + {command, ecosystem, Opts1} = atomvm_spectrometer:parse_args([ + "ecosystem", "--github-only" + ]), + ?assertEqual(true, maps:get(github, Opts1)), + ?assertEqual(false, maps:get(hex, Opts1)), + {command, ecosystem, Opts2} = atomvm_spectrometer:parse_args([ + "ecosystem", "--hex-only" + ]), + ?assertEqual(false, maps:get(github, Opts2)), + ?assertEqual(true, maps:get(hex, Opts2)) + end}. + +parse_ecosystem_args_limit_test_() -> + {"parses --limit N", fun() -> + {command, ecosystem, Opts} = atomvm_spectrometer:parse_args([ + "ecosystem", "--limit", "100" + ]), + ?assertEqual(100, maps:get(limit, Opts)) + end}. + +parse_ecosystem_args_resume_test_() -> + {"parses --resume", fun() -> + {command, ecosystem, Opts} = atomvm_spectrometer:parse_args([ + "ecosystem", "--resume" + ]), + ?assertEqual(true, maps:get(resume, Opts)) + end}. + +parse_ecosystem_args_invalid_workers_test_() -> + {"returns error for invalid --workers", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args([ + "ecosystem", "--workers", "abc" + ]), + ?assert(string:str(Msg, "Invalid") > 0) + end}. + +parse_ecosystem_args_invalid_limit_test_() -> + {"returns error for invalid --limit value", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args([ + "ecosystem", "--limit", "abc" + ]), + ?assert(string:str(Msg, "Invalid") > 0) + end}. + +%% ============================================================================= +%% parse_supported_args/2 tests +%% ============================================================================= + +parse_supported_args_basic_test_() -> + {"parses supported command", fun() -> + {command, supported, Opts} = atomvm_spectrometer:parse_args([ + "supported" + ]), + ?assertEqual(true, is_map(Opts)) + end}. + +parse_supported_args_module_test_() -> + {"parses --module option", fun() -> + {command, supported, Opts} = atomvm_spectrometer:parse_args([ + "supported", "--module", "lists" + ]), + ?assertEqual(lists, maps:get(module, Opts)) + end}. + +parse_supported_args_short_module_test_() -> + {"parses -m option", fun() -> + {command, supported, Opts} = atomvm_spectrometer:parse_args([ + "supported", "-m", "maps" + ]), + ?assertEqual(maps, maps:get(module, Opts)) + end}. + +%% ============================================================================= +%% parse_filter_args/2 tests +%% ============================================================================= + +parse_filter_args_csv_file_test_() -> + {"parses CSV file argument", fun() -> + {command, filter, Opts} = atomvm_spectrometer:parse_args([ + "filter", "results.csv" + ]), + ?assertEqual("results.csv", maps:get(csv_file, Opts)), + ?assertEqual(1, maps:get(min_repos, Opts)) + end}. + +parse_filter_args_min_repos_test_() -> + {"parses --min-repos N", fun() -> + {command, filter, Opts} = atomvm_spectrometer:parse_args([ + "filter", "results.csv", "--min-repos", "10" + ]), + ?assertEqual("results.csv", maps:get(csv_file, Opts)), + ?assertEqual(10, maps:get(min_repos, Opts)) + end}. + +parse_filter_args_no_csv_test_() -> + {"allows no CSV file (loads from binary state)", fun() -> + {command, filter, Opts} = atomvm_spectrometer:parse_args(["filter"]), + %% Should not have csv_file key, will load from binary state at runtime + ?assertNot(maps:is_key(csv_file, Opts)), + ?assertEqual(1, maps:get(min_repos, Opts)) + end}. + +parse_filter_args_csv_option_test_() -> + {"parses --csv option", fun() -> + {command, filter, Opts} = atomvm_spectrometer:parse_args([ + "filter", "--csv", "data.csv" + ]), + ?assertEqual("data.csv", maps:get(csv_file, Opts)) + end}. + +parse_filter_args_multiple_csv_test_() -> + {"returns error for multiple CSV files", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args([ + "filter", "file1.csv", "file2.csv" + ]), + ?assert(string:str(Msg, "unsupported option file2.csv") > 0) + end}. + +parse_filter_args_invalid_min_repos_test_() -> + {"returns error for invalid --min-repos", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args([ + "filter", "results.csv", "--min-repos", "abc" + ]), + ?assert(string:str(Msg, "Invalid") > 0) + end}. + +parse_filter_args_flag_as_file_test_() -> + {"returns error for flag-shaped option where csv_file expected", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args([ + "filter", "--unknown-flag" + ]), + ?assert(string:str(Msg, "unknown option") > 0), + {error, Msg2} = atomvm_spectrometer:parse_args([ + "filter", "-x" + ]), + ?assert(string:str(Msg2, "unknown option") > 0) + end}. + +%% ============================================================================= +%% parse_update_args/2 tests +%% ============================================================================= + +parse_update_args_defaults_test_() -> + {"uses default options", fun() -> + {command, update, Opts} = atomvm_spectrometer:parse_args(["update"]), + ?assertEqual("main", maps:get(branch, Opts)), + ?assertEqual(true, maps:get(tests, Opts)) + end}. + +parse_update_args_atomvm_dir_test_() -> + {"parses --atomvm-dir", fun() -> + {command, update, Opts} = atomvm_spectrometer:parse_args([ + "update", "--atomvm-dir", "~/work/AtomVM" + ]), + ?assertEqual("~/work/AtomVM", maps:get(atomvm_dir, Opts)) + end}. + +parse_update_args_branch_test_() -> + {"parses --branch", fun() -> + {command, update, Opts} = atomvm_spectrometer:parse_args([ + "update", "--branch", "release-0.6" + ]), + ?assertEqual("release-0.6", maps:get(branch, Opts)) + end}. + +parse_update_args_tag_test_() -> + {"parses --tag", fun() -> + {command, update, Opts} = atomvm_spectrometer:parse_args([ + "update", "--tag", "v0.6.5" + ]), + ?assertEqual("v0.6.5", maps:get(tag, Opts)) + end}. + +parse_update_args_no_tests_test_() -> + {"parses --no-tests", fun() -> + {command, update, Opts} = atomvm_spectrometer:parse_args([ + "update", "--no-tests" + ]), + ?assertEqual(false, maps:get(tests, Opts)) + end}. + +parse_update_args_force_test_() -> + {"parses --force", fun() -> + {command, update, Opts} = atomvm_spectrometer:parse_args([ + "update", "--force" + ]), + ?assertEqual(true, maps:get(force, Opts)) + end}. + +parse_update_args_output_test_() -> + {"parses --output", fun() -> + {command, update, Opts} = atomvm_spectrometer:parse_args([ + "update", "--output", "~/custom.term" + ]), + ?assertEqual("~/custom.term", maps:get(output, Opts)) + end}. + +parse_update_args_unknown_test_() -> + {"returns error for unknown option", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args(["update", "--unknown"]), + ?assert(string:str(Msg, "Unknown option") > 0) + end}. + +%% ============================================================================= +%% parse_query_args/2 tests +%% ============================================================================= + +parse_query_args_basic_test_() -> + {"parses query argument", fun() -> + {command, query, Opts} = atomvm_spectrometer:parse_args([ + "query", "lists:map" + ]), + ?assertEqual("lists:map", maps:get(query, Opts)) + end}. + +parse_query_args_with_arity_test_() -> + {"parses query with arity", fun() -> + {command, query, Opts} = atomvm_spectrometer:parse_args([ + "query", "lists:map/2" + ]), + ?assertEqual("lists:map/2", maps:get(query, Opts)) + end}. + +parse_query_args_missing_test_() -> + {"returns error for missing query", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args(["query"]), + ?assert( + string:str(Msg, "No function") > 0 orelse + string:str(Msg, "No query") > 0 + ) + end}. + +parse_query_args_multiple_test_() -> + {"returns error for multiple queries", fun() -> + {error, Msg} = atomvm_spectrometer:parse_args([ + "query", "lists:map", "maps:get" + ]), + ?assert(string:str(Msg, "Multiple queries") > 0) + end}. + +%% ============================================================================= +%% parse_query_string/1 tests +%% ============================================================================= + +parse_query_string_basic_test_() -> + {"parses Module:Function", fun() -> + ?assertEqual( + {ok, lists, map}, + spectrometer_atomvm:parse_query_string("lists:map") + ) + end}. + +parse_query_string_with_arity_test_() -> + {"parses Module:Function/Arity", fun() -> + ?assertEqual( + {ok, lists, map, 2}, + spectrometer_atomvm:parse_query_string("lists:map/2") + ), + ?assertEqual( + {ok, gen_server, call, 3}, + spectrometer_atomvm:parse_query_string( + "gen_server:call/3" + ) + ), + ?assertEqual( + {ok, file, read_file, 1}, + spectrometer_atomvm:parse_query_string("file:read_file/1") + ) + end}. + +parse_query_string_zero_arity_test_() -> + {"parses zero arity", fun() -> + ?assertEqual( + {ok, erlang, now, 0}, + spectrometer_atomvm:parse_query_string("erlang:now/0") + ) + end}. + +parse_query_string_unknown_module_test_() -> + {"returns ok for non-existent module", fun() -> + ?assertEqual( + {ok, nonexistent_module_xyz, foo}, + spectrometer_atomvm:parse_query_string("nonexistent_module_xyz:foo") + ) + end}. + +parse_query_string_missing_colon_test_() -> + {"returns error for missing colon", fun() -> + {error, _} = spectrometer_atomvm:parse_query_string("foobar"), + {error, Msg1} = spectrometer_atomvm:parse_query_string("foobar"), + ?assert(string:str(Msg1, "Invalid format") > 0) + end}. + +parse_query_string_invalid_arity_test_() -> + {"returns error for invalid arity", fun() -> + {error, Msg} = spectrometer_atomvm:parse_query_string("foo:bar/abc"), + ?assert(string:str(Msg, "Invalid arity") > 0) + end}. + +%% ============================================================================= +%% Helper function tests +%% ============================================================================= + +parse_target_lines_test_() -> + {"parses multi-file target lines", fun() -> + Lines = [ + "https://github.com/user/repo", + "hex:jsx", + "/path/to/local/dir", + "", + "# This is a comment", + "https://github.com/other/project.git" + ], + Targets = spectrometer_analyzer:parse_target_lines(Lines), + ?assertEqual(4, length(Targets)), + ?assert( + lists:member({github_url, "https://github.com/user/repo"}, Targets) + ), + ?assert(lists:member({hex, "jsx"}, Targets)), + ?assert( + lists:member({github_url, "/path/to/local/dir"}, Targets) + ), + ?assert( + lists:member( + {github_url, "https://github.com/other/project.git"}, Targets + ) + ) + end}. + +parse_target_lines_local_dir_test_() -> + {"detects local directories", fun() -> + %% Create a temp directory to test local dir detection + Dir = spectrometer_utils:make_temp_dir("test_local_dir_"), + ok = filelib:ensure_path(Dir), + try + Lines = [Dir], + Targets = spectrometer_analyzer:parse_target_lines(Lines), + ?assert(lists:member({local_dir, Dir}, Targets)) + after + cleanup_temp_dir(Dir) + end + end}. + +cleanup_temp_dir(Dir) -> + case file:del_dir_r(Dir) of + ok -> + ok; + {error, Reason} -> + io:format("Warning: failed to cleanup ~s: ~p\n", [Dir, Reason]) + end. + +format_platforms_test_() -> + {"formats platform lists", fun() -> + ?assertEqual( + "all", spectrometer_atomvm:format_platforms(all) + ), + ?assertEqual("esp32", spectrometer_atomvm:format_platforms([esp32])), + ?assertEqual( + "esp32, rp2", spectrometer_atomvm:format_platforms([esp32, rp2]) + ), + ?assertEqual( + "esp32, stm32, rp2", + spectrometer_atomvm:format_platforms([esp32, stm32, rp2]) + ) + end}. + +merge_repo_stats_test_() -> + {"merges repository statistics", fun() -> + RepoStats = #{ + {lists, map, 2} => 10, + {io, format, 2} => 5 + }, + GlobalStats = #{ + {lists, map, 2} => {20, 2}, + {string, len, 1} => {7, 1} + }, + Result = spectrometer_ecosystem:merge_repo_stats( + RepoStats, GlobalStats + ), + %% Should sum total calls and repo count + {TotalCalls1, RepoCount1} = maps:get({lists, map, 2}, Result), + ?assertEqual(30, TotalCalls1), + ?assertEqual(3, RepoCount1), + {_, RepoCount2} = maps:get({io, format, 2}, Result), + ?assertEqual(1, RepoCount2) + end}. + +work_key_test_() -> + {"generates unique work keys", fun() -> + ?assertEqual( + "github:user/repo", + spectrometer_ecosystem:work_key(github, #{full_name => "user/repo"}) + ), + ?assertEqual( + "hex:jsx", + spectrometer_ecosystem:work_key(hex, #{name => "jsx"}) + ) + end}. + +deduplicate_test_() -> + {"removes duplicate repos", fun() -> + GithubRepos = [ + #{ + full_name => "user/repo", + html_url => "https://github.com/user/repo", + clone_url => "https://github.com/user/repo.git", + stars => 100 + } + ], + HexPackages = [ + #{ + name => "repo", + version => "1.0.0", + github_url => "https://github.com/user/repo" + } + ], + {FilteredGithub, FilteredHex} = spectrometer_ecosystem:deduplicate( + GithubRepos, HexPackages + ), + %% GitHub repos should remain + ?assertEqual(1, length(FilteredGithub)), + %% Hex package with same GitHub URL should be filtered out + ?assertEqual(0, length(FilteredHex)) + end}. + +is_otp_module_test_() -> + {"identifies OTP modules", fun() -> + ?assert(spectrometer_otp:is_otp_module(lists)), + ?assert(spectrometer_otp:is_otp_module("lists")), + ?assert(spectrometer_otp:is_otp_module(io)), + ?assert(spectrometer_otp:is_otp_module("io")), + ?assert(spectrometer_otp:is_otp_module("gen_server")), + ?assertNot(spectrometer_otp:is_otp_module(some_random_fun)), + ?assertNot(spectrometer_otp:is_otp_module("my_app_helper")), + ?assertNot(spectrometer_otp:is_otp_module("nonexistent_module_xyz")) + end}. + +parse_csv_rows_test_() -> + {"parses CSV data rows", fun() -> + %% parse_csv_rows expects data lines only (header already removed by caller) + Lines = [ + "lists,map,2,10,3", + "io,format,2,5,2", + "" + ], + Rows = spectrometer_analyzer:parse_csv_rows(Lines), + ?assertEqual(2, length(Rows)), + {Mod1, Fun1, Arity1, Calls1, RC1} = hd(Rows), + ?assertEqual("lists", Mod1), + ?assertEqual("map", Fun1), + ?assertEqual(2, Arity1), + ?assertEqual(10, Calls1), + ?assertEqual(3, RC1) + end}. + +parse_csv_rows_with_repo_count_test_() -> + {"parses CSV with repo_count column", fun() -> + Lines = [ + "lists,map,2,10,5", + "" + ], + Rows = spectrometer_analyzer:parse_csv_rows(Lines), + ?assertEqual(1, length(Rows)), + {_, _, _, _, RC} = hd(Rows), + ?assertEqual(5, RC) + end}. + +%% ============================================================================= +%% Usage/help output tests (verify functions exist and return ok) +%% ============================================================================= + +usage_functions_exist_test_() -> + {"all usage functions return ok", fun() -> + ?assertEqual(ok, spectrometer_help:usage()), + ?assertEqual(ok, spectrometer_help:usage(audit)), + ?assertEqual(ok, spectrometer_help:usage(ecosystem)), + ?assertEqual(ok, spectrometer_help:usage(supported)), + ?assertEqual(ok, spectrometer_help:usage(filter)), + ?assertEqual(ok, spectrometer_help:usage(update)), + ?assertEqual(ok, spectrometer_help:usage(query)) + end}. diff --git a/test/cli_main_tests.erl b/test/cli_main_tests.erl new file mode 100644 index 0000000..d7d5d53 --- /dev/null +++ b/test/cli_main_tests.erl @@ -0,0 +1,893 @@ +%% +%% 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(cli_main_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% Helper functions +%% ============================================================================= + +create_erl_file(Dir, Name, Content) -> + Path = filename:join(Dir, Name), + ok = file:write_file(Path, Content), + Path. + +%% Create an AtomVM repo clone in the OS temp directory. +%% Returns {TempDir, AtomVMDir} where TempDir is the parent temp dir. +ensure_atomvm_repo() -> + TempDir = spectrometer_utils:make_temp_dir("spectrometer_git_clone_"), + AtomVMDir = filename:join(TempDir, "AtomVM"), + spectrometer_utils:purge_dir(AtomVMDir), + io:format(" Cloning AtomVM repo to ~s...\n", [AtomVMDir]), + case + spectrometer_utils:run_git_command( + [ + "clone", + "--quiet", + "--depth", + "1", + "https://github.com/atomvm/AtomVM.git", + AtomVMDir + ], + [{"GIT_TERMINAL_PROMPT", "0"}] + ) + of + {ok, ""} -> + io:format(" Clone successful\n"), + {TempDir, AtomVMDir}; + {ok, Output} -> + io:format(" Clone output: ~s", [Output]), + {TempDir, AtomVMDir}; + {error, {exit_status, Status, Output}} -> + io:format(" Clone failed (exit ~p): ~p\n", [Status, Output]), + error({clone_failed, {Status, Output}}) + end. + +%% ============================================================================= +%% 1. Help and Error Paths — calling main/1 directly +%% ============================================================================= + +main_empty_args_test_() -> + {"main([]) returns ok and prints usage", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main([])) + end}. + +main_help_flag_test_() -> + {"main(['--help']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["--help"])) + end}. + +main_short_help_test_() -> + {"main(['-h']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["-h"])) + end}. + +main_help_command_test_() -> + {"main(['help']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["help"])) + end}. + +main_help_audit_test_() -> + {"main(['help', 'audit']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["help", "audit"])) + end}. + +main_help_ecosystem_test_() -> + {"main(['help', 'ecosystem']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["help", "ecosystem"])) + end}. + +main_help_supported_test_() -> + {"main(['help', 'supported']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["help", "supported"])) + end}. + +main_help_filter_test_() -> + {"main(['help', 'filter']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["help", "filter"])) + end}. + +main_help_update_test_() -> + {"main(['help', 'update']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["help", "update"])) + end}. + +main_help_query_test_() -> + {"main(['help', 'query']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["help", "query"])) + end}. + +main_help_unknown_test_() -> + {"main(['help', 'unknown']) returns error", fun() -> + ?assertMatch( + {error, {halt, 1}}, atomvm_spectrometer:main(["help", "unknown"]) + ) + end}. + +main_audit_short_help_test_() -> + {"main(['audit', '-h']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["audit", "-h"])) + end}. + +main_ecosystem_long_help_test_() -> + {"main(['ecosystem', '--help']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["ecosystem", "--help"])) + end}. + +main_unknown_command_test_() -> + {"main(['unknown_command']) returns error tuple", fun() -> + ?assertMatch( + {error, {halt, 1}}, + atomvm_spectrometer:main(["unknown_command"]) + ) + end}. + +%% ============================================================================= +%% 2. `supported` Command +%% ============================================================================= + +main_supported_all_test_() -> + {"main(['supported']) returns ok and lists modules", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["supported"])) + end}. + +main_supported_module_lists_test_() -> + {"main(['supported', '--module', 'lists']) returns ok", fun() -> + ?assertEqual( + ok, atomvm_spectrometer:main(["supported", "--module", "lists"]) + ) + end}. + +main_supported_module_maps_test_() -> + {"main(['supported', '-m', 'maps']) returns ok", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["supported", "-m", "maps"])) + end}. + +main_supported_module_nonexistent_test_() -> + {"main(['supported', '--module', 'nonexistent_xyz']) returns ok with stderr error", + fun() -> + ?assertEqual( + {error, {halt, 1}}, + atomvm_spectrometer:main([ + "supported", "--module", "nonexistent_module_xyz" + ]) + ) + end}. + +%% ============================================================================= +%% 3. `query` Command +%% ============================================================================= + +main_query_supported_test_() -> + {"main(['query', 'lists:map']) returns ok, shows supported", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["query", "lists:map"])) + end}. + +main_query_supported_with_arity_test_() -> + {"main(['query', 'lists:map/2']) returns ok, shows specific arity", fun() -> + ?assertEqual(ok, atomvm_spectrometer:main(["query", "lists:map/2"])) + end}. + +main_query_unsupported_test_() -> + {"main(['query', 'lists:nonexistent_func']) returns ok, shows unsupported", + fun() -> + ?assertEqual( + ok, + atomvm_spectrometer:main(["query", "lists:nonexistent_func"]) + ) + end}. + +main_query_unknown_mod_function_test_() -> + {"main(['query', 'nonexistent_mod:func']) returns ok, shows unsupported", + fun() -> + ?assertEqual( + ok, + atomvm_spectrometer:main(["query", "nonexistent_mod:func"]) + ) + end}. + +main_query_invalid_format_test_() -> + {"main(['query', 'invalid_format']) returns error", fun() -> + ?assertMatch( + {error, {halt, 1}}, + atomvm_spectrometer:main(["query", "invalid_format"]) + ) + end}. + +main_query_invalid_arity_test_() -> + {"main(['query', 'lists:map/abc']) returns error", fun() -> + ?assertMatch( + {error, {halt, 1}}, + atomvm_spectrometer:main(["query", "lists:map/abc"]) + ) + end}. + +main_query_module_nofun_test_() -> + {"main(['query', 'nonexistent_mod']) returns error", fun() -> + ?assertMatch( + {error, {halt, 1}}, + atomvm_spectrometer:main(["query", "nonexistent_mod"]) + ) + end}. + +%% ============================================================================= +%% 4. `audit` Command — Local Directory (with fixtures) +%% ============================================================================= + +main_audit_dir_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("audit_dir_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + create_erl_file( + Dir, + "test.erl", + "-module(test).\n" + "-export([foo/0]).\n" + "foo() -> lists:map(fun(X) -> X end, [1,2,3]).\n" + ), + Result = atomvm_spectrometer:main(["audit", "--dir", Dir]), + ?assertEqual(ok, Result) + end) + end + ]} + }. + +main_audit_empty_dir_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("audit_empty_dir_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Result = atomvm_spectrometer:main(["audit", "--dir", Dir]), + ?assertEqual(ok, Result) + end) + end + ]} + }. + +main_audit_dir_with_output_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("audit_dir_output_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + CsvFile = filename:join(Dir, "report.csv"), + create_erl_file( + Dir, + "test.erl", + "-module(test).\n" + "-export([foo/0]).\n" + "foo() -> lists:map(fun(X) -> X end, [1]).\n" + ), + Result = atomvm_spectrometer:main([ + "audit", "--dir", Dir, "-o", CsvFile + ]), + ?assertEqual(ok, Result), + ?assert(filelib:is_file(CsvFile)), + {ok, Content} = file:read_file(CsvFile), + ?assert( + string:str(binary_to_list(Content), "module,function") > + 0 + ) + end) + end + ]} + }. + +main_audit_missing_dir_test_() -> + { + setup, + fun() -> + Unique = + "missing_test_" ++ + integer_to_list(erlang:unique_integer([positive])), + TempDir = spectrometer_utils:make_temp_dir(Unique), + {TempDir, filename:join(TempDir, "missing_child")} + end, + fun({TempDir, _MissingDir}) -> + spectrometer_utils:purge_dir(TempDir) + end, + {with, [ + fun({_TempDir, MissingDir}) -> + ?_test(begin + Result = atomvm_spectrometer:main([ + "audit", "--dir", MissingDir + ]), + ?assertEqual(ok, Result) + end) + end + ]} + }. + +%% ============================================================================= +%% 5. `audit` Command — Error Paths +%% ============================================================================= + +main_audit_no_target_test_() -> + {"main(['audit']) returns error for missing target", fun() -> + ?assertMatch({error, {halt, 1}}, atomvm_spectrometer:main(["audit"])) + end}. + +main_audit_unknown_option_test_() -> + {"main(['audit', '--unknown']) returns error", fun() -> + ?assertMatch( + {error, {halt, 1}}, + atomvm_spectrometer:main([ + "audit", "--github", "https://github.com/user/repo", "--unknown" + ]) + ) + end}. + +%% ============================================================================= +%% 6. `filter` Command +%% ============================================================================= + +main_filter_no_csv_test_() -> + { + setup, + fun() -> + Unique = + "filter_test_" ++ + integer_to_list(erlang:unique_integer([positive])), + TempDir = spectrometer_utils:make_temp_dir(Unique), + {TempDir, filename:join(TempDir, "nonexistent_cache")} + end, + fun({TempDir, _}) -> + spectrometer_utils:purge_dir(TempDir) + end, + {with, [ + fun({_TempDir, MissingCache}) -> + ?_test(begin + Result = atomvm_spectrometer:main([ + "filter", "-c", MissingCache + ]), + ?assertEqual({error, {halt, 1}}, Result) + end) + end + ]} + }. + +main_filter_no_user_state_test_() -> + { + setup, + fun() -> + Prev = application:get_env(spectrometer, cache_dir), + CacheDir = spectrometer_utils:make_temp_dir("mock_cache_"), + ok = filelib:ensure_path(CacheDir), + application:unset_env(spectrometer, cache_dir), + {CacheDir, Prev} + end, + fun({CacheDir, Prev}) -> + case Prev of + undefined -> + application:unset_env(spectrometer, cache_dir); + {ok, Val} -> + application:set_env(spectrometer, cache_dir, Val) + end, + spectrometer_utils:purge_dir(CacheDir) + end, + {with, [ + fun({CacheDir, Prev}) -> + ?assertEqual( + undefined, application:get_env(spectrometer, cache_dir) + ), + ?_test(begin + application:set_env(spectrometer, cache_dir, CacheDir), + Result = atomvm_spectrometer:main([ + "filter", "--min-repos", "10" + ]), + ?assertEqual({error, {halt, 1}}, Result), + case Prev of + undefined -> + application:unset_env(spectrometer, cache_dir); + {ok, Val} -> + application:set_env(spectrometer, cache_dir, Val) + end, + spectrometer_atomvm:reload_db() + end) + end + ]} + }. + +main_filter_min_repos_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"main(['filter', '--min-repos', '1']) returns ok on success", + fun() -> + CacheDir = spectrometer_utils:make_temp_dir("mock_cache_"), + ok = filelib:ensure_path(CacheDir), + Prev = application:get_env(spectrometer, cache_dir), + application:set_env(spectrometer, cache_dir, CacheDir), + try + ok = atomvm_spectrometer:main([ + "ecosystem", "--limit", "5" + ]), + Result = atomvm_spectrometer:main([ + "filter", "--min-repos", "1", "--cache", CacheDir + ]), + ?assertEqual(ok, Result) + after + case Prev of + undefined -> + application:unset_env(spectrometer, cache_dir); + {ok, Val} -> + application:set_env( + spectrometer, cache_dir, Val + ) + end, + spectrometer_utils:purge_dir(CacheDir) + end + end}; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +main_filter_invalid_min_repos_test_() -> + {"main(['filter', '--min-repos', 'abc']) returns error", fun() -> + ?assertMatch( + {error, {halt, 1}}, + atomvm_spectrometer:main(["filter", "--min-repos", "abc"]) + ) + end}. + +%% ============================================================================= +%% 7. Mock Package Test +%% ============================================================================= + +main_query_mock_function_test_() -> + { + setup, + fun() -> + CacheDir = spectrometer_utils:make_temp_dir("mock_cache_"), + ok = filelib:ensure_path(CacheDir), + Prev = application:get_env(spectrometer, cache_dir), + application:set_env(spectrometer, cache_dir, CacheDir), + {CacheDir, Prev} + end, + fun({CacheDir, Prev}) -> + case Prev of + undefined -> application:unset_env(spectrometer, cache_dir); + {ok, Val} -> application:set_env(spectrometer, cache_dir, Val) + end, + spectrometer_atomvm:reload_db(), + spectrometer_utils:purge_dir(CacheDir) + end, + {with, [ + fun({CacheDir, _Prev}) -> + ?_test(begin + CustomDB = [ + {mock_pkg, [ + {custom_func, 1, all, {unreleased, <<"0.7.x">>}} + ]}, + {lists, [{map, 2, all, <<"v0.5.0">>}]} + ], + DbFile = filename:join( + CacheDir, "supported_functions.data" + ), + ok = file:write_file( + DbFile, io_lib:format("~p.\n", [CustomDB]) + ), + Result = atomvm_spectrometer:main([ + "query", "-c", CacheDir, "mock_pkg:custom_func/1" + ]), + ?assertEqual(ok, Result) + end) + end + ]} + }. + +main_supported_mock_module_test_() -> + { + setup, + fun() -> + CacheDir = spectrometer_utils:make_temp_dir("mock_cache_"), + ok = filelib:ensure_path(CacheDir), + Prev = application:get_env(spectrometer, cache_dir), + application:set_env(spectrometer, cache_dir, CacheDir), + {CacheDir, Prev} + end, + fun({CacheDir, Prev}) -> + case Prev of + undefined -> application:unset_env(spectrometer, cache_dir); + {ok, Val} -> application:set_env(spectrometer, cache_dir, Val) + end, + spectrometer_atomvm:reload_db(), + spectrometer_utils:purge_dir(CacheDir) + end, + {with, [ + fun({CacheDir, _Prev}) -> + ?_test(begin + CustomDB = [ + {mock_pkg, [ + {custom_func, 1, all, {unreleased, <<"0.7.x">>}}, + {another_func, 2, all, {unreleased, <<"0.7.x">>}} + ]} + ], + DbFile = filename:join( + CacheDir, "supported_functions.data" + ), + ok = file:write_file( + DbFile, io_lib:format("~p.\n", [CustomDB]) + ), + Result = atomvm_spectrometer:main([ + "supported", "-c", CacheDir, "--module", "mock_pkg" + ]), + ?assertEqual(ok, Result) + end) + end + ]} + }. + +%% ============================================================================= +%% 8. audit --github (network test) +%% ============================================================================= + +main_audit_github_small_repo_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"main(['audit', '--github', 'https://github.com/atomvm/atomvm_lora']) audits fully supported repo", + fun() -> + Result = atomvm_spectrometer:main([ + "audit", + "--github", + "https://github.com/atomvm/atomvm_lora" + ]), + ?assertEqual(ok, Result) + end}; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +%% ============================================================================= +%% 9. audit --hex (network test) +%% ============================================================================= + +main_audit_hex_package_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"main(['audit', '--hex', 'cowboy']) audits package with unsupported functions", + fun() -> + ?assertEqual( + ok, + atomvm_spectrometer:main(["audit", "--hex", "cowboy"]) + ) + end}; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +%% ============================================================================= +%% 10. Update command (network test - requires AtomVM repo) +%% ============================================================================= + +main_update_with_local_repo_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + { + setup, + fun() -> + {TempDir, AtomVMDir} = ensure_atomvm_repo(), + CacheDir = spectrometer_utils:make_temp_dir( + "update_test_cache_" + ), + OutputFile = filename:join( + CacheDir, + "test_" ++ + integer_to_list(erlang:unique_integer([positive])) ++ + ".data" + ), + Prev = application:get_env(spectrometer, cache_dir), + application:set_env(spectrometer, cache_dir, CacheDir), + spectrometer_atomvm:reload_db(), + {{TempDir, AtomVMDir}, OutputFile, CacheDir, Prev} + end, + fun({{TempDir, _AtomVMDir}, _OutputFile, CacheDir, Prev}) -> + case Prev of + undefined -> + application:unset_env(spectrometer, cache_dir); + {ok, Val} -> + application:set_env(spectrometer, cache_dir, Val) + end, + spectrometer_atomvm:reload_db(), + spectrometer_utils:purge_dir(TempDir), + spectrometer_utils:purge_dir(CacheDir) + end, + {with, [ + fun({{_TempDir, AtomVMDir}, OutputFile, CacheDir, _Prev}) -> + ?_test(begin + Result = atomvm_spectrometer:main([ + "update", + "--atomvm-dir", + AtomVMDir, + "--output", + OutputFile, + "-c", + CacheDir, + "--force" + ]), + ?assertEqual(ok, Result), + ?assert(filelib:is_file(OutputFile)), + {ok, [Data]} = file:consult(OutputFile), + ?assert(is_list(Data)), + ?assert( + lists:any(fun({M, _}) -> M =:= erlang end, Data) + ) + end) + end + ]} + }; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +main_update_no_force_overwrite_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + { + setup, + fun() -> + {TempDir, AtomVMDir} = ensure_atomvm_repo(), + CacheDir = spectrometer_utils:make_temp_dir( + "update_noforce_cache_" + ), + Prev = application:get_env(spectrometer, cache_dir), + application:set_env(spectrometer, cache_dir, CacheDir), + {{TempDir, AtomVMDir}, CacheDir, Prev} + end, + fun({{TempDir, _AtomVMDir}, CacheDir, Prev}) -> + case Prev of + undefined -> + application:unset_env(spectrometer, cache_dir); + {ok, Val} -> + application:set_env(spectrometer, cache_dir, Val) + end, + spectrometer_atomvm:reload_db(), + spectrometer_utils:purge_dir(CacheDir), + spectrometer_utils:purge_dir(TempDir) + end, + {with, [ + fun({{_TempDir, AtomVMDir}, CacheDir, _Prev}) -> + ?_test(begin + OutputFile = filename:join( + CacheDir, + "update_noforce_" ++ + integer_to_list( + erlang:unique_integer([positive]) + ) ++ + ".data" + ), + ok = file:write_file(OutputFile, "dummy"), + Result = atomvm_spectrometer:main([ + "update", + "--atomvm-dir", + AtomVMDir, + "--output", + OutputFile, + "-c", + CacheDir + ]), + ?assertMatch({error, {halt, 1}}, Result) + end) + end + ]} + }; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +%% ============================================================================= +%% Filter command tests +%% ============================================================================= + +filter_csv_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("filter_csv_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + CsvFile = filename:join(Dir, "test.csv"), + CsvContent = + "module,function,arity,calls,repos\n" + "lists,map,2,100,42\n" + "lists,filter,2,50,38\n" + "maps,get,2,30,21\n", + ok = file:write_file(CsvFile, CsvContent), + Result = atomvm_spectrometer:main([ + "filter", "--csv", CsvFile + ]), + ?assertEqual(ok, Result) + end) + end + ]} + }. + +filter_csv_invalid_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("filter_csv_invalid_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + CsvFile = filename:join(Dir, "bad.csv"), + CsvContent = + "module,function,arity,calls,atomvm_supported\n" + "lists,map\n" + "lists,filter,2,50,no\n", + ok = file:write_file(CsvFile, CsvContent), + Result = atomvm_spectrometer:main([ + "filter", "--csv", CsvFile + ]), + ?assertEqual(ok, Result) + end) + end + ]} + }. + +filter_min_repos_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("filter_min-repos_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + CsvFile = filename:join(Dir, "repos.csv"), + CsvContent = + "module,function,arity,calls,repo_count\n" + "lists,map,2,100,5\n" + "lists,filter,2,50,2\n" + "lists,reverse,2,30,10\n", + ok = file:write_file(CsvFile, CsvContent), + Result = atomvm_spectrometer:main([ + "filter", "--csv", CsvFile, "--min-repos", "5" + ]), + ?assertEqual(ok, Result) + end) + end + ]} + }. + +filter_avm_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("filter_avm_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + CsvFile = filename:join(Dir, "avm.csv"), + CsvContent = + "module,function,arity,calls,repo_count\n" + "lists,map,2,100,24\n" + "re,run,3,57,52\n", + ok = file:write_file(CsvFile, CsvContent), + Result = atomvm_spectrometer:main([ + "filter", "--csv", CsvFile, "--avm" + ]), + ?assertEqual(ok, Result) + end) + end + ]} + }. + +%% ============================================================================= +%% Update command tests +%% ============================================================================= + +update_force_existing_db_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("update_force_existing_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun(Dir) -> + Prev = application:get_env(spectrometer, cache_dir), + case Prev of + undefined -> ok; + {ok, _} -> application:unset_env(spectrometer, cache_dir) + end, + spectrometer_atomvm:reload_db(), + spectrometer_utils:purge_dir(Dir) + end, + {with, [ + fun(Dir) -> + ?_test(begin + OutputFile = filename:join(Dir, "output.data"), + CacheDir = filename:join(Dir, "cache"), + AtomVMDir = filename:join(Dir, "AtomVM"), + LibDir = filename:join(AtomVMDir, "src/libAtomVM"), + ok = filelib:ensure_path(LibDir), + ok = file:write_file( + filename:join(LibDir, "bifs.gperf"), + "{\n erlang:abs/1, BIF_ERLANG_ABS_1\n}\n" + ), + ok = file:write_file( + filename:join(LibDir, "nifs.gperf"), + "{\n \"binary:at/2\", nif_binary_at_2\n}\n" + ), + ExistingDB = [ + {erlang, [{abs, 1, all, {unreleased, <<"main">>}}]}, + {io, [{format, 2, all, {unreleased, <<"main">>}}]} + ], + ok = file:write_file( + OutputFile, io_lib:format("~p.\n", [ExistingDB]) + ), + Result = atomvm_spectrometer:main([ + "update", + "--atomvm-dir", + AtomVMDir, + "--output", + OutputFile, + "--force", + "-c", + CacheDir + ]), + ?assertEqual(ok, Result), + {ok, [MergedDB]} = file:consult(OutputFile), + ?assertMatch( + [{format, 2, all, {unreleased, <<"main">>}}], + proplists:get_value(io, MergedDB) + ), + ?assertMatch( + [{abs, 1, all, {unreleased, <<"main">>}}], + proplists:get_value(erlang, MergedDB) + ), + ?assertMatch( + [{at, 2, all, {unreleased, <<"main">>}}], + proplists:get_value(binary, MergedDB) + ), + ?assertEqual(3, length(MergedDB)), + ?assert(filelib:is_file(OutputFile)) + end) + end + ]} + }. diff --git a/test/spectrometer_analyzer_tests.erl b/test/spectrometer_analyzer_tests.erl new file mode 100644 index 0000000..1b934b6 --- /dev/null +++ b/test/spectrometer_analyzer_tests.erl @@ -0,0 +1,208 @@ +%% +%% 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_analyzer_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% scan_target/1 tests +%% ============================================================================= + +scan_local_dir_test_() -> + {"scans local directory", fun() -> + Dir = setup_temp_dir(), + try + Source = + "-module(test).\nfoo() -> lists:map(fun(X) -> X end, [1]).\n", + ok = file:write_file(filename:join(Dir, "test.erl"), Source), + Stats = spectrometer_analyzer:scan_target({local_dir, Dir}), + ?assert(is_map(Stats)), + ?assert(maps:is_key({lists, map, 2}, Stats)) + after + spectrometer_utils:purge_dir(Dir) + end + end}. + +scan_local_dir_empty_test_() -> + {"returns empty map for empty directory", fun() -> + Dir = setup_temp_dir(), + try + Stats = spectrometer_analyzer:scan_target({local_dir, Dir}), + ?assertEqual(0, maps:size(Stats)) + after + spectrometer_utils:purge_dir(Dir) + end + end}. + +scan_local_dir_nonexistent_test_() -> + {"returns empty map for non-existent directory", fun() -> + TempParent = spectrometer_utils:make_temp_dir("analyzer_test_"), + MissingChild = filename:join(TempParent, "nonexistent_child"), + try + Stats = spectrometer_analyzer:scan_target( + {local_dir, MissingChild} + ), + ?assertEqual(0, maps:size(Stats)) + after + spectrometer_utils:purge_dir(TempParent) + end + end}. + +%% ============================================================================= +%% merge_stats/2 tests +%% ============================================================================= + +merge_stats_basic_test_() -> + {"merges two stats maps", fun() -> + Stats1 = #{{lists, map, 2} => 10, {io, format, 2} => 5}, + Stats2 = #{{lists, map, 2} => 3, {string, len, 1} => 7}, + Result = spectrometer_analyzer:merge_stats(Stats1, Stats2), + ?assertEqual(13, maps:get({lists, map, 2}, Result)), + ?assertEqual(5, maps:get({io, format, 2}, Result)), + ?assertEqual(7, maps:get({string, len, 1}, Result)) + end}. + +merge_stats_sums_test_() -> + {"sums counts for duplicate keys", fun() -> + Stats1 = #{{lists, map, 2} => 5}, + Stats2 = #{{lists, map, 2} => 10}, + Result = spectrometer_analyzer:merge_stats(Stats1, Stats2), + ?assertEqual(15, maps:get({lists, map, 2}, Result)) + end}. + +merge_stats_unique_test_() -> + {"preserves unique keys from both maps", fun() -> + Stats1 = #{{lists, map, 2} => 5}, + Stats2 = #{{io, format, 2} => 3}, + Result = spectrometer_analyzer:merge_stats(Stats1, Stats2), + ?assertEqual(2, maps:size(Result)) + end}. + +merge_stats_empty_left_test_() -> + {"handles empty left map", fun() -> + Stats1 = #{}, + Stats2 = #{{lists, map, 2} => 5}, + Result = spectrometer_analyzer:merge_stats(Stats1, Stats2), + ?assertEqual(1, maps:size(Result)) + end}. + +merge_stats_empty_right_test_() -> + {"handles empty right map", fun() -> + Stats1 = #{{lists, map, 2} => 5}, + Stats2 = #{}, + Result = spectrometer_analyzer:merge_stats(Stats1, Stats2), + ?assertEqual(1, maps:size(Result)) + end}. + +merge_stats_both_empty_test_() -> + {"handles both empty maps", fun() -> + Stats1 = #{}, + Stats2 = #{}, + Result = spectrometer_analyzer:merge_stats(Stats1, Stats2), + ?assertEqual(0, maps:size(Result)) + end}. + +merge_stats_order_independent_test_() -> + {"order-independent merging", fun() -> + Stats1 = #{{lists, map, 2} => 5, {io, format, 2} => 3}, + Stats2 = #{{lists, map, 2} => 10}, + Result1 = spectrometer_analyzer:merge_stats(Stats1, Stats2), + Result2 = spectrometer_analyzer:merge_stats(Stats2, Stats1), + ?assertEqual(Result1, Result2) + end}. + +%% ============================================================================= +%% scan_target/1 network tests (GitHub and Hex) +%% ============================================================================= + +scan_target_github_url_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"scans GitHub repository URL", fun() -> + %% Use a known Erlang repo + Stats = spectrometer_analyzer:scan_target( + {github_url, "https://github.com/atomvm/atomvm_packbeam"} + ), + ?assert(is_map(Stats)), + %% Should return non-empty map for a real Erlang repo + ?assert(map_size(Stats) > 0), + ?assert(maps:is_key({io, format, 1}, Stats)), + ?assert(maps:is_key({proplists, get_value, 2}, Stats)) + end}; + _ -> + {"skipped (network tests disabled)", fun() -> ok end} + end. + +scan_target_github_url_nonexistent_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"handles non-existent GitHub repo", fun() -> + Stats = spectrometer_analyzer:scan_target( + {github_url, + "https://github.com/nonexistent-user-12345/nonexistent-repo-12345"} + ), + %% Should return empty map for failed clone + ?assertEqual(0, maps:size(Stats)) + end}; + _ -> + {"skipped (network tests disabled)", fun() -> ok end} + end. + +scan_target_hex_package_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"scans Hex package", fun() -> + %% Use a small known Hex package + Stats = spectrometer_analyzer:scan_target( + {hex, "atomvm_packbeam", "0.8.1"} + ), + ?assert(is_map(Stats)), + %% Verify we found some function calls (specific keys may change) + ?assert(maps:size(Stats) > 0), + ?assert(maps:is_key({lists, member, 2}, Stats)), + ?assert(maps:is_key({erlang, is_map, 1}, Stats)) + end}; + _ -> + {"skipped (network tests disabled)", fun() -> ok end} + end. + +scan_target_hex_package_nonexistent_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"handles non-existent Hex package", fun() -> + Stats = spectrometer_analyzer:scan_target( + {hex, "nonexistent_package_12345", "0.0.1"} + ), + %% Should return empty map for failed download + ?assertEqual(0, maps:size(Stats)) + end}; + _ -> + {"skipped (network tests disabled)", fun() -> ok end} + end. + +scan_target_hex_latest_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"scans Hex package with latest version", fun() -> + %% Test the "latest" version resolution + Stats = spectrometer_analyzer:scan_target( + {hex, "jason", "latest"} + ), + ?assert(is_map(Stats)) + end}; + _ -> + {"skipped (network tests disabled)", fun() -> ok end} + end. + +%% ============================================================================= +%% Test helpers +%% ============================================================================= + +setup_temp_dir() -> + spectrometer_utils:make_temp_dir("analyzer_test_"). diff --git a/test/spectrometer_atomvm_tests.erl b/test/spectrometer_atomvm_tests.erl new file mode 100644 index 0000000..ade7b89 --- /dev/null +++ b/test/spectrometer_atomvm_tests.erl @@ -0,0 +1,388 @@ +%% +%% 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_atomvm_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% supported_modules/0 tests +%% ============================================================================= + +supported_modules_test_() -> + [ + {"returns list of atoms", + ?_assert(begin + Mods = spectrometer_atomvm:supported_modules(), + is_list(Mods) andalso lists:all(fun is_atom/1, Mods) + end)}, + + {"contains expected OTP modules", + ?_assert(begin + Mods = spectrometer_atomvm:supported_modules(), + lists:member(lists, Mods) andalso + lists:member(maps, Mods) andalso + lists:member(erlang, Mods) andalso + lists:member(io, Mods) + end)}, + + {"returns non-empty list", + ?_assert(begin + Mods = spectrometer_atomvm:supported_modules(), + length(Mods) > 0 + end)} + ]. + +%% ============================================================================= +%% supported_functions/0 tests +%% ============================================================================= + +supported_functions_test_() -> + [ + {"returns list of 5-tuples", + ?_assert(begin + Funs = spectrometer_atomvm:get_supported_functions(), + is_list(Funs) andalso + lists:all( + fun({M, F, A, _P, _S}) -> + is_atom(M) andalso is_atom(F) andalso is_integer(A) + end, + Funs + ) + end)}, + + {"contains expected functions", + ?_assert(begin + Funs = spectrometer_atomvm:get_supported_functions(), + lists:any( + fun + ({lists, map, 2, _, _}) -> true; + (_) -> false + end, + Funs + ) andalso + lists:any( + fun + ({maps, get, 2, _, _}) -> true; + (_) -> false + end, + Funs + ) + end)}, + + {"all entries have valid atoms and integer arities", + ?_assert(begin + Funs = spectrometer_atomvm:get_supported_functions(), + lists:all( + fun({M, F, A, _P, _S}) -> + is_atom(M) andalso is_atom(F) andalso is_integer(A) andalso + A >= 0 + end, + Funs + ) + end)} + ]. + +%% ============================================================================= +%% is_supported/1 tests +%% ============================================================================= + +is_supported_test_() -> + [ + {"returns true for supported function", + ?_assert(spectrometer_atomvm:is_supported({lists, map, 2}))}, + + {"returns false for unsupported function", + ?_assertNot( + spectrometer_atomvm:is_supported( + {nonexistent_module, foo, 0} + ) + )}, + + {"handles specific arity match", + ?_assert(spectrometer_atomvm:is_supported({io, format, 2}))}, + + {"handles unknown module", + ?_assertNot( + spectrometer_atomvm:is_supported({unknown_module, test, 1}) + )}, + + {"handles unknown function", + ?_assert(begin + Mods = spectrometer_atomvm:supported_modules(), + case Mods of + [Mod | _] -> + not spectrometer_atomvm:is_supported( + {Mod, nonexistent_function_12345, 0} + ); + [] -> + true + end + end)}, + + {"handles erlang BIFs", + ?_assert( + spectrometer_atomvm:is_supported({erlang, atom_to_list, 1}) + )}, + + {"handles erlang operators", + ?_assert(spectrometer_atomvm:is_supported({erlang, '+', 2}))} + ]. + +%% ============================================================================= +%% is_supported/1 platform-specific tests +%% ============================================================================= + +is_supported_with_platforms_test_() -> + [ + {"returns {true, all, Since} for functions on all platforms", + ?_assertEqual( + true, + spectrometer_atomvm:is_supported({lists, map, 2}) + )}, + + {"returns false for unsupported functions", + ?_assertEqual( + false, + spectrometer_atomvm:is_supported({nonexistent, foo, 0}) + )} + ]. + +%% ============================================================================= +%% support_info/1 tests +%% ============================================================================= + +support_info_test_() -> + [ + {"returns {true, all, Since} for functions on all platforms", + ?_assertMatch( + {true, all, _}, + spectrometer_atomvm:support_info({lists, map, 2}) + )}, + + {"returns false for unsupported functions", + ?_assertEqual( + false, + spectrometer_atomvm:support_info({nonexistent, foo, 0}) + )}, + + {"returns since info for known functions", + ?_assert(begin + %% Current data file has version info + Result = spectrometer_atomvm:support_info({lists, map, 2}), + match_all_platforms_since(Result) + end)} + ]. + +%% Helper to check if result has valid since info +match_all_platforms_since({true, all, Since}) when + is_binary(Since) orelse is_tuple(Since) +-> + true; +match_all_platforms_since(_) -> + false. + +%% ============================================================================= +%% supported_functions_with_platforms/0 tests +%% ============================================================================= + +supported_functions_with_platforms_test_() -> + [ + {"returns list with platform and since information", + ?_assert(begin + Funs = spectrometer_atomvm:get_supported_functions(), + is_list(Funs) andalso + lists:all( + fun({M, F, A, P, S}) -> + is_atom(M) andalso is_atom(F) andalso + is_integer(A) andalso + (P =:= all orelse is_list(P)) andalso + (is_binary(S) orelse + (is_tuple(S) andalso + element(1, S) =:= unreleased)) + end, + Funs + ) + end)}, + + {"has valid since info for known functions", + ?_assert(begin + Funs = spectrometer_atomvm:get_supported_functions(), + %% Find lists:map/2 and check it has valid since info + case + lists:keyfind( + {lists, map, 2}, + 1, + [{{M, F, A}, {P, S}} || {M, F, A, P, S} <- Funs] + ) + of + {_, {all, Since}} when is_binary(Since) -> true; + _ -> false + end + end)} + ]. + +%% ============================================================================= +%% get_unsupported/1 tests +%% ============================================================================= + +get_unsupported_test_() -> + [ + {"filters out supported functions from stats", + ?_assert(begin + Stats = #{ + {lists, map, 2} => 10, + {nonexistent_module, foo, 0} => 5 + }, + Unsupported = spectrometer_atomvm:get_unsupported(Stats), + Keys = [K || {K, _} <- Unsupported], + not lists:member({lists, map, 2}, Keys) andalso + lists:member({nonexistent_module, foo, 0}, Keys) + end)}, + + {"returns only unsupported functions", + ?_assert(begin + Stats = #{ + {nonexistent1, foo, 0} => 5, + {nonexistent2, bar, 1} => 3 + }, + Unsupported = spectrometer_atomvm:get_unsupported(Stats), + length(Unsupported) =:= 2 + end)}, + + {"sorts by call count descending", + ?_assertEqual( + [ + {{nonexistent1, foo, 0}, 10}, + {{nonexistent2, bar, 1}, 5} + ], + spectrometer_atomvm:get_unsupported(#{ + {nonexistent2, bar, 1} => 5, + {nonexistent1, foo, 0} => 10 + }) + )}, + + {"returns empty list when all are supported", + ?_assertEqual( + [], + spectrometer_atomvm:get_unsupported(#{ + {lists, map, 2} => 10 + }) + )}, + + {"returns all when none are supported", + ?_assertEqual( + [ + {{nonexistent1, foo, 0}, 5}, + {{nonexistent2, bar, 1}, 3} + ], + lists:sort( + fun({_, C1}, {_, C2}) -> C1 > C2 end, + spectrometer_atomvm:get_unsupported(#{ + {nonexistent1, foo, 0} => 5, + {nonexistent2, bar, 1} => 3 + }) + ) + )} + ]. + +%% ============================================================================= +%% Database loading tests +%% ============================================================================= + +db_loading_test_() -> + [ + {"load_db/0 returns a map", fun() -> + ?assert(is_map(spectrometer_atomvm:load_db())) + end}, + + {"reload_db/0 clears cache", fun() -> + DB1 = spectrometer_atomvm:load_db(), + ok = spectrometer_atomvm:reload_db(), + %% Write a different DB to the cache location to verify reload picks it up + CacheDir = spectrometer_utils:user_cache_path(), + AltDir = spectrometer_utils:make_temp_dir("alt_cache_"), + ok = filelib:ensure_path(AltDir), + AltDbFile = filename:join(AltDir, "supported_functions.data"), + %% Write a minimal DB with a known entry + AltDB = [ + {test_mod, [{test_fun, 0, all, {unreleased, <<"test">>}}]} + ], + ok = file:write_file(AltDbFile, io_lib:format("~p.\n", [AltDB])), + try + %% Point cache to the alt dir and reload + application:set_env(spectrometer, cache_dir, AltDir), + ok = spectrometer_atomvm:reload_db(), + DB2 = spectrometer_atomvm:load_db(), + ?assert(DB1 =/= DB2) + after + %% Restore original cache dir + application:set_env(spectrometer, cache_dir, CacheDir), + spectrometer_atomvm:reload_db() + end + end}, + + {"bundled_data_path/0 returns a string", fun() -> + Path = spectrometer_utils:bundled_data_path(), + ?assert(is_list(Path)) + end}, + + {"user_cache_path/0 returns platform-appropriate path", fun() -> + Path = spectrometer_utils:user_cache_path(), + ?assert( + is_list(Path) andalso + %% Should contain our app name + string:str(Path, "spectrometer") > 0 + ) + end} + ]. + +%% ============================================================================= +%% consult_db/1 error path tests +%% ============================================================================= + +consult_db_invalid_test_() -> + {"returns empty map for invalid DB file", fun() -> + Dir = spectrometer_utils:make_temp_dir("consult_db_test_"), + File = filename:join( + Dir, + "invalid_db_" ++ integer_to_list(erlang:unique_integer([positive])) ++ + ".data" + ), + %% Write a non-list term as text so file:consult can parse it + ok = file:write_file(File, io_lib:format("~s\n", [not_a_list])), + try + %% Should return empty map and print warning + DB = spectrometer_atomvm:consult_db(File), + ?assertEqual(#{}, DB) + after + spectrometer_utils:purge_dir(Dir) + end + end}. + +consult_db_nonexistent_test_() -> + {"returns empty map for nonexistent file", fun() -> + DB = spectrometer_atomvm:consult_db("/nonexistent/path/to/db.data"), + ?assertEqual(#{}, DB) + end}. + +%% ============================================================================= +%% is_supported/1 with different arities +%% ============================================================================= + +is_supported_arity_mismatch_test_() -> + {"correctly distinguishes supported and unsupported arities", fun() -> + %% Tests that is_supported/1 returns correct boolean for known arities. + %% Note: The DB currently uses separate entries per arity (not list arities). + Result1 = spectrometer_atomvm:is_supported({erlang, send, 1}), + Result2 = spectrometer_atomvm:is_supported({erlang, send, 2}), + %% send/1 is NOT supported by AtomVM (only send/2) + ?assertEqual(false, Result1), + %% send/2 is supported + ?assertEqual(true, Result2) + end}. diff --git a/test/spectrometer_http_tests.erl b/test/spectrometer_http_tests.erl new file mode 100644 index 0000000..6dfef1c --- /dev/null +++ b/test/spectrometer_http_tests.erl @@ -0,0 +1,428 @@ +%% +%% 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_http_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% download_github_repo/2 tests +%% ============================================================================= + +download_github_repo_success_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"downloads public repo to temp directory (no credentials needed)", + fun() -> + Dir = setup_temp_dir(), + TmpDir = filename:join(Dir, "repo"), + _ = filelib:ensure_path(TmpDir), + try + %% Use a small public repo that doesn't require authentication + %% GIT_TERMINAL_PROMPT=0 is set in the source to prevent credential prompts + _ = spectrometer_http:download_github_repo( + "https://github.com/githubtraining/hellogitworld.git", + TmpDir + ), + ?assert(filelib:is_dir(TmpDir)), + %% Verify the repo was actually cloned with some content + Files = filelib:wildcard("**/*", TmpDir), + ?assert(length(Files) > 1) + after + cleanup_temp_dir(Dir) + end + end}; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +download_github_repo_invalid_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"handles non-existent repo gracefully", fun() -> + Dir = setup_temp_dir(), + TmpDir = filename:join(Dir, "repo"), + _ = filelib:ensure_path(TmpDir), + try + _ = spectrometer_http:download_github_repo( + "https://github.com/nonexistent-user-12345/nonexistent-repo-12345", + TmpDir + ), + %% Git may succeed (empty repo) or fail - either is acceptable + %% The important thing is no Erlang source files were cloned + Files = filelib:wildcard("**/*.erl", TmpDir), + ?assertEqual(0, length(Files)), + ok + after + cleanup_temp_dir(Dir) + end + end}; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +%% ============================================================================= +%% download_hex_tarball/2 tests +%% ============================================================================= + +download_hex_tarball_valid_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"downloads and extracts valid tarball", fun() -> + %% Download a small known package with Erlang source files + case spectrometer_http:download_hex_tarball("jsx", "3.1.0") of + {ok, Dir} -> + try + ?assert(filelib:is_dir(Dir)), + %% Verify it has content files + Files = filelib:wildcard("**/*.erl", Dir), + ?assert(length(Files) > 0) + after + spectrometer_utils:purge_dir(Dir) + end; + {error, Reason} -> + erlang:error({hex_download_failed, Reason}) + end + end}; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +download_hex_tarball_nonexistent_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"handles missing package versions", fun() -> + Result = spectrometer_http:download_hex_tarball( + "nonexistent_package_12345", "0.0.1" + ), + ?assertMatch({error, _}, Result) + end}; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +download_hex_tarball_cleanup_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"cleans up temp directory on error", fun() -> + %% Before download + TempDirs1 = list_temp_dirs(), + _ = spectrometer_http:download_hex_tarball( + "nonexistent_package_12345", "0.0.1" + ), + %% After download - should not leave temp dirs + TempDirs2 = list_temp_dirs(), + %% Same or fewer temp dirs + ?assert(length(TempDirs2) =< length(TempDirs1) + 1) + end}; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +%% ============================================================================= +%% fetch_github_repos/1 tests +%% ============================================================================= + +fetch_github_repos_test_() -> + {"fetches GitHub repos via Search API (requires OTP 27+ for json module)", + fun() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + %% Fetch a small number of repos to test API connectivity and parsing + Repos = spectrometer_http:fetch_github_repos({2, 2000}), + ?assert(is_list(Repos)), + ?assert(length(Repos) >= 1), + %% Verify structure of first repo + Repo = hd(Repos), + ?assert(is_map(Repo)), + ?assert(maps:is_key(full_name, Repo)), + ?assert(maps:is_key(clone_url, Repo)), + ?assert(maps:is_key(html_url, Repo)), + ?assert(maps:is_key(stars, Repo)), + %% Verify types + ?assert(is_list(maps:get(full_name, Repo))), + ?assert(is_integer(maps:get(stars, Repo))); + _ -> + ok + end + end}. + +fetch_github_cursor_advances_test_() -> + {"fetch_github_cursor advances cursor to fetch different repos (no duplicates)", + fun() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + %% Fetch more repos than the API returns a single page to verify + %% that the cursor advances and we don't get duplicate repos + Repos = spectrometer_http:fetch_github_repos({100, 1}), + ?assert(is_list(Repos)), + ?assert(length(Repos) >= 50), + %% Verify no duplicate repos (full_name should be unique) + FullNames = [maps:get(full_name, R) || R <- Repos], + UniqueNames = lists:usort(FullNames), + ?assertEqual(length(UniqueNames), length(FullNames)); + _ -> + ok + end + end}. + +%% ============================================================================= +%% fetch_hex_packages/1 tests +%% ============================================================================= + +fetch_hex_packages_test_() -> + {"fetches Hex packages via Hex API (requires OTP 27+ for json module)", + fun() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + %% Fetch a small number of packages to test API connectivity + Packages = spectrometer_http:fetch_hex_packages(2), + ?assert(is_list(Packages)), + ?assert(length(Packages) >= 1), + %% Verify structure of first package + Pkg = hd(Packages), + ?assert(is_map(Pkg)), + ?assert(maps:is_key(name, Pkg)), + ?assert(maps:is_key(version, Pkg)), + ?assert(maps:is_key(github_url, Pkg)), + %% Verify types + ?assert(is_list(maps:get(name, Pkg))), + ?assert(is_list(maps:get(version, Pkg))), + ?assert(is_list(maps:get(github_url, Pkg))); + _ -> + ok + end + end}. + +fetch_hex_packages_large_limit_test_() -> + {"fetches Hex packages with higher limit (requires OTP 27+ for json module)", + fun() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + %% Fetch more packages to test pagination logic + Packages = spectrometer_http:fetch_hex_packages(150), + ?assert(is_list(Packages)), + %% Should return up to 150 packages (may be less due to API limits) + ?assert(length(Packages) >= 1), + %% All packages should have valid structure + lists:foreach( + fun(Pkg) -> + ?assert(is_map(Pkg)), + ?assert(maps:is_key(name, Pkg)), + ?assert(maps:is_key(version, Pkg)) + end, + Packages + ); + _ -> + ok + end + end}. + +%% ============================================================================= +%% find_github_link/1 tests (internal, test via exported wrapper if available) +%% ============================================================================= + +find_github_link_extracts_github_url_test_() -> + {"extracts GitHub URL from links map when present", fun() -> + Links = #{ + <<"github">> => <<"https://github.com/user/repo">>, + <<"hex">> => <<"https://hex.pm/packages/pkg">> + }, + Result = spectrometer_http:find_github_link(Links), + ?assertEqual("https://github.com/user/repo", Result) + end}. + +find_github_link_no_github_returns_empty_test_() -> + {"returns empty string when no GitHub URL in links", fun() -> + Links = #{ + <<"hex">> => <<"https://hex.pm/packages/pkg">>, + <<"docs">> => <<"https://hexdocs.pm/pkg">> + }, + Result = spectrometer_http:find_github_link(Links), + ?assertEqual("", Result) + end}. + +find_github_link_empty_map_test_() -> + {"returns empty string for empty map", fun() -> + Result = spectrometer_http:find_github_link(#{}), + ?assertEqual("", Result) + end}. + +find_github_link_non_map_test_() -> + {"returns empty string for non-map input", fun() -> + ?assertEqual("", spectrometer_http:find_github_link([])), + ?assertEqual("", spectrometer_http:find_github_link(undefined)), + ?assertEqual("", spectrometer_http:find_github_link(<<"not a map">>)) + end}. + +find_github_link_multiple_returns_a_github_url_test_() -> + {"returns a GitHub URL when multiple exist in map", fun() -> + Links = #{ + <<"source">> => <<"https://gitlab.com/user/repo">>, + <<"github">> => <<"https://github.com/owner/project">>, + <<"fork">> => <<"https://github.com/fork/project">> + }, + Result = spectrometer_http:find_github_link(Links), + %% Function returns the first GitHub URL found during map iteration + ?assert(string:find(Result, "github.com") =/= nomatch) + end}. + +find_github_link_skips_non_github_test_() -> + {"skips non-GitHub URLs even when they appear first", fun() -> + Links = #{ + <<"source">> => <<"https://gitlab.com/user/repo">>, + <<"other">> => <<"https://bitbucket.org/user/repo">> + }, + Result = spectrometer_http:find_github_link(Links), + ?assertEqual("", Result) + end}. + +%% ============================================================================= +%% validate_tar_path/1 tests +%% ============================================================================= + +validate_tar_path_valid_relative_test_() -> + {"accepts valid relative paths", fun() -> + ?assert(spectrometer_http:validate_tar_path("src/module.erl")), + ?assert(spectrometer_http:validate_tar_path("lib/foo/bar.ex")), + ?assert(spectrometer_http:validate_tar_path("README.md")), + ?assert(spectrometer_http:validate_tar_path("a/b/c/d.txt")) + end}. + +validate_tar_path_rejects_dotdot_test_() -> + {"rejects paths with .. segments", fun() -> + ?assertNot(spectrometer_http:validate_tar_path("../etc/passwd")), + ?assertNot(spectrometer_http:validate_tar_path("src/../../secret")), + ?assertNot(spectrometer_http:validate_tar_path("a/b/../..")), + ?assertNot(spectrometer_http:validate_tar_path("..")) + end}. + +validate_tar_path_rejects_absolute_unix_test_() -> + {"rejects absolute Unix paths", fun() -> + ?assertNot(spectrometer_http:validate_tar_path("/etc/passwd")), + ?assertNot(spectrometer_http:validate_tar_path("/root/.ssh/id_rsa")), + ?assertNot(spectrometer_http:validate_tar_path("/absolute/path")) + end}. + +validate_tar_path_rejects_absolute_windows_test_() -> + {"rejects absolute Windows paths", fun() -> + ?assertNot( + spectrometer_http:validate_tar_path("C:\\Windows\\System32") + ), + ?assertNot(spectrometer_http:validate_tar_path("D:/secret/file.txt")) + end}. + +validate_tar_path_rejects_empty_test_() -> + {"rejects empty paths", fun() -> + ?assertNot(spectrometer_http:validate_tar_path("")) + end}. + +%% ============================================================================= +%% validate_tar_paths/1 tests +%% ============================================================================= + +validate_tar_paths_all_valid_test_() -> + {"accepts all valid paths", fun() -> + ?assertEqual( + ok, + spectrometer_http:validate_tar_paths([ + "src/module.erl", + "include/header.hrl", + "test/module_tests.erl" + ]) + ) + end}. + +validate_tar_paths_with_malicious_test_() -> + {"rejects paths containing traversal attempts", fun() -> + ?assertEqual( + {error, path_traversal_attempt}, + spectrometer_http:validate_tar_paths([ + "src/module.erl", + "../../../etc/passwd" + ]) + ), + ?assertEqual( + {error, path_traversal_attempt}, + spectrometer_http:validate_tar_paths([ + "/etc/passwd", + "src/module.erl" + ]) + ), + ?assertEqual( + {error, path_traversal_attempt}, + spectrometer_http:validate_tar_paths([ + "src/..", + "test/test.erl" + ]) + ) + end}. + +validate_tar_paths_empty_list_test_() -> + {"handles empty path list", fun() -> + ?assertEqual(ok, spectrometer_http:validate_tar_paths([])) + end}. + +%% ============================================================================= +%% Integration tests (if network available) +%% ============================================================================= + +integration_hex_small_package_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + {"fetches small Hex package", fun() -> + case spectrometer_http:download_hex_tarball("jsx", "3.1.0") of + {ok, Dir} -> + try + ?assert(filelib:is_dir(Dir)), + %% Verify it has some content + Files = filelib:wildcard("**/*.erl", Dir), + ?assert(length(Files) > 0) + after + spectrometer_utils:purge_dir(Dir) + end; + {error, Reason} -> + erlang:error({hex_download_failed, Reason}) + end + end}; + _ -> + {"skipped (SKIP_NETWORK_TESTS set)", fun() -> ok end} + end. + +%% ============================================================================= +%% Test helpers +%% ============================================================================= + +setup_temp_dir() -> + Dir = spectrometer_utils:make_temp_dir("http_test_"), + ok = filelib:ensure_path(Dir), + Dir. + +cleanup_temp_dir(Dir) -> + case file:del_dir_r(Dir) of + ok -> + ok; + {error, Reason} -> + io:format("Warning: failed to cleanup ~s: ~p\n", [Dir, Reason]) + end. + +list_temp_dirs() -> + CacheDir = filename:join( + spectrometer_utils:system_temp_dir(), "spectrometer" + ), + case file:list_dir(CacheDir) of + {ok, Entries} -> + lists:filter( + fun(E) -> + lists:prefix("hex_", E) orelse lists:prefix("gh_", E) + end, + Entries + ); + {error, _} -> + [] + end. diff --git a/test/spectrometer_reporter_tests.erl b/test/spectrometer_reporter_tests.erl new file mode 100644 index 0000000..d944a09 --- /dev/null +++ b/test/spectrometer_reporter_tests.erl @@ -0,0 +1,76 @@ +%% +%% 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_reporter_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% write_csv/2 tests +%% ============================================================================= + +write_csv_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("reporter_test_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Stats = #{ + {lists, map, 2} => {all, <<"v1.0.0">>}, + {io, format, 2} => {[esp32], <<"v2.0.0">>} + }, + Path = filename:join(Dir, "output.csv"), + ok = spectrometer_reporter:write_csv(Path, Stats), + ?assert(filelib:is_file(Path)), + {ok, Content} = file:read_file(Path), + ?assert( + binary:match(Content, <<"lists,map,2">>) =/= nomatch + ), + ?assert( + binary:match(Content, <<"io,format,2">>) =/= nomatch + ) + end) + end + ]} + }. + +write_csv_limit_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("reporter_limit_test_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {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">>} + }, + Path = filename:join(Dir, "output.csv"), + ok = spectrometer_reporter:write_csv(Path, Stats), + ?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)) + end) + end + ]} + }. diff --git a/test/spectrometer_scanner_tests.erl b/test/spectrometer_scanner_tests.erl new file mode 100644 index 0000000..46e10af --- /dev/null +++ b/test/spectrometer_scanner_tests.erl @@ -0,0 +1,245 @@ +%% +%% 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_scanner_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% find_erl_files/1 tests (simple tests) +%% ============================================================================= + +find_erl_files_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_simple_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + create_file(Dir, "mod1.erl", "-module(mod1).\n"), + create_file(Dir, "mod2.erl", "-module(mod2).\n"), + create_file(Dir, "readme.txt", "not erlang"), + Expected = find_expected(Dir, ["mod1.erl", "mod2.erl"]), + Result = spectrometer_scanner:find_erl_files(Dir), + ?assertEqual(lists:sort(Expected), lists:sort(Result)) + end) + end, + fun(Dir) -> + ?_test(begin + create_file(Dir, "mod1.erl", "-module(mod1).\n"), + SubDir = filename:join(Dir, "src"), + ok = file:make_dir(SubDir), + create_file(SubDir, "mod2.erl", "-module(mod2).\n"), + Result = spectrometer_scanner:find_erl_files(Dir), + ?assert(length(Result) =:= 2), + ?assert( + lists:any( + fun(F) -> filename:basename(F) =:= "mod1.erl" end, + Result + ) + ), + ?assert( + lists:any( + fun(F) -> filename:basename(F) =:= "mod2.erl" end, + Result + ) + ) + end) + end, + fun(Dir) -> + ?_test(begin + Result = spectrometer_scanner:find_erl_files(Dir), + ?assertEqual([], Result) + end) + end + ]} + }. + +find_erl_files_nonexistent_test_() -> + {"returns empty list for non-existent directory", fun() -> + Result = spectrometer_scanner:find_erl_files( + "/nonexistent/path/12345" + ), + ?assertEqual([], Result) + end}. + +%% ============================================================================= +%% parse_file/1 tests (simple tests) +%% ============================================================================= + +parse_file_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_parse_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Source = + "-module(test).\n-export([foo/0]).\nfoo() -> lists:map(fun(X) -> X + 1 end, [1,2,3]).\n", + File = create_file(Dir, "test.erl", Source), + {ok, Calls} = spectrometer_scanner:parse_file(File), + ?assert(is_map(Calls)), + ?assert(maps:is_key({lists, map, 2}, Calls)) + end) + end, + fun(Dir) -> + ?_test(begin + Source = + "-module(nocalls).\n-export([foo/0]).\nfoo() -> 42.\n", + File = create_file(Dir, "nocalls.erl", Source), + {ok, Calls} = spectrometer_scanner:parse_file(File), + ?assertEqual(0, maps:size(Calls)) + end) + end, + fun(Dir) -> + ?_test(begin + Source = + "-module(multi).\n-export([test/0]).\n" + "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", + File = create_file(Dir, "multi.erl", Source), + {ok, Calls} = spectrometer_scanner:parse_file(File), + ?assert(maps:is_key({lists, map, 2}, Calls)), + ?assert(maps:is_key({lists, filter, 2}, Calls)), + ?assert(maps:is_key({io, format, 2}, Calls)) + end) + end + ]} + }. + +parse_file_nonexistent_test_() -> + {"returns error for non-existent file", fun() -> + Result = spectrometer_scanner:parse_file("/nonexistent/file.erl"), + ?assertMatch({error, _}, Result) + end}. + +%% ============================================================================= +%% merge_file_calls/2 tests +%% ============================================================================= + +merge_file_calls_test_() -> + [ + {"merges two stats maps correctly", fun() -> + Result = spectrometer_scanner:merge_file_calls( + #{{lists, map, 2} => 2, {io, format, 2} => 1}, + #{{lists, map, 2} => 1} + ), + ?assertEqual(3, maps:get({lists, map, 2}, Result)), + ?assertEqual(1, maps:get({io, format, 2}, Result)) + end}, + + {"sums counts for duplicate keys", fun() -> + Result = spectrometer_scanner:merge_file_calls( + #{{lists, map, 2} => 3}, + #{{lists, map, 2} => 2} + ), + ?assertEqual(5, maps:get({lists, map, 2}, Result)) + end}, + + {"preserves unique keys", fun() -> + Result = spectrometer_scanner:merge_file_calls( + #{{lists, map, 2} => 1}, + #{{io, format, 2} => 1} + ), + ?assertEqual(2, maps:size(Result)) + end}, + + {"handles empty maps - left", fun() -> + Result = spectrometer_scanner:merge_file_calls( + #{}, + #{{lists, map, 2} => 1} + ), + ?assertEqual(1, maps:size(Result)) + end}, + + {"handles empty maps - right", fun() -> + Result = spectrometer_scanner:merge_file_calls( + #{{lists, map, 2} => 1}, + #{} + ), + ?assertEqual(1, maps:size(Result)) + end} + ]. + +%% ============================================================================= +%% scan_directory/1 tests +%% ============================================================================= + +scan_directory_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("scanner_scan_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Source = + "-module(test).\nfoo() -> lists:map(fun(X) -> X end, [1]).\n", + create_file(Dir, "test.erl", Source), + Stats = spectrometer_scanner:scan_directory(Dir), + ?assert(is_map(Stats)), + ?assert(maps:is_key({lists, map, 2}, Stats)), + ?assertEqual(1, maps:get({lists, map, 2}, Stats)) + end) + end, + fun(Dir) -> + ?_test(begin + Stats = spectrometer_scanner:scan_directory(Dir), + ?assertEqual(0, maps:size(Stats)) + end) + end, + fun(Dir) -> + ?_test(begin + Source1 = + "-module(mod1).\nfoo() -> lists:map(fun(X) -> X end, [1]).\n", + Source2 = + "-module(mod2).\nbar() -> lists:map(fun(X) -> X end, [2]).\n", + create_file(Dir, "mod1.erl", Source1), + create_file(Dir, "mod2.erl", Source2), + Stats = spectrometer_scanner:scan_directory(Dir), + ?assertEqual(2, maps:get({lists, map, 2}, Stats)) + end) + end + ]} + }. + +scan_directory_nonexistent_test_() -> + {"returns empty map for non-existent directory", fun() -> + Stats = spectrometer_scanner:scan_directory( + "/nonexistent/path/12345" + ), + ?assertEqual(0, maps:size(Stats)) + end}. + +%% ============================================================================= +%% Test helpers +%% ============================================================================= + +create_file(Dir, Name, Content) -> + Path = filename:join(Dir, Name), + ok = file:write_file(Path, Content), + Path. + +find_expected(Dir, Basenames) -> + [filename:join(Dir, B) || B <- Basenames]. diff --git a/test/spectrometer_updater_tests.erl b/test/spectrometer_updater_tests.erl new file mode 100644 index 0000000..42d9df1 --- /dev/null +++ b/test/spectrometer_updater_tests.erl @@ -0,0 +1,582 @@ +%% +%% 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_updater_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% derive_since/2 tests +%% ============================================================================= + +derive_since_tag_test_() -> + {"tag strips prerelease suffixes", fun() -> + ?assertEqual( + <<"v0.5.0">>, + spectrometer_updater:derive_since("v0.5.0-alpha.1", "main") + ), + ?assertEqual( + <<"v0.6.0">>, + spectrometer_updater:derive_since("v0.6.0-rc.2", "release-0.7") + ), + ?assertEqual( + <<"v0.5.0">>, + spectrometer_updater:derive_since("v0.5.0", undefined) + ), + ?assertEqual( + <<"v1.0.0">>, + spectrometer_updater:derive_since("v1.0.0-beta.3", "main") + ) + end}. + +derive_since_branch_test_() -> + {"branch converts to since", fun() -> + ?assertEqual( + {unreleased, <<"main">>}, + spectrometer_updater:derive_since(undefined, "main") + ), + ?assertEqual( + {unreleased, <<"0.7.x">>}, + spectrometer_updater:derive_since(undefined, "release-0.7") + ), + ?assertEqual( + {unreleased, <<"feature-x">>}, + spectrometer_updater:derive_since(undefined, "feature-x") + ) + end}. + +derive_since_undefined_test_() -> + {"undefined/undefined returns default", fun() -> + ?assertEqual( + {unreleased, <<"main">>}, + spectrometer_updater:derive_since(undefined, undefined) + ) + end}. + +%% ============================================================================= +%% is_older_since/2 tests +%% ============================================================================= + +is_older_since_binary_test_() -> + {"compares two binary tags", fun() -> + ?assert( + spectrometer_updater:is_older_since(<<"v0.4.0">>, <<"v0.5.0">>) + ), + ?assert( + spectrometer_updater:is_older_since(<<"v0.8.2">>, <<"v1.0.1">>) + ), + ?assert( + spectrometer_updater:is_older_since(<<"v0.2.9">>, <<"v0.2.11">>) + ), + ?assertNot( + spectrometer_updater:is_older_since(<<"v0.5.1">>, <<"v0.5.0">>) + ), + ?assertNot( + spectrometer_updater:is_older_since(<<"v0.5.0">>, <<"v0.4.0">>) + ) + end}. + +is_older_since_tag_vs_unreleased_test_() -> + {"tag is always older than unreleased", fun() -> + ?assert( + spectrometer_updater:is_older_since( + <<"v0.5.0">>, {unreleased, <<"main">>} + ) + ), + ?assertNot( + spectrometer_updater:is_older_since( + {unreleased, <<"main">>}, <<"v0.5.0">> + ) + ) + end}. + +is_older_since_both_unreleased_test_() -> + {"main is newer than versioned branches", fun() -> + ?assertNot( + spectrometer_updater:is_older_since( + {unreleased, <<"main">>}, {unreleased, <<"0.7.x">>} + ) + ), + ?assert( + spectrometer_updater:is_older_since( + {unreleased, <<"0.7.x">>}, {unreleased, <<"main">>} + ) + ), + ?assertNot( + spectrometer_updater:is_older_since( + {unreleased, <<"0.7.x">>}, {unreleased, <<"0.6.x">>} + ) + ) + end}. + +%% ============================================================================= +%% merge_entry/2 and merge_platforms_all/2 tests +%% ============================================================================= + +merge_entry_both_all_test_() -> + {"merges two all-platform entries", fun() -> + E1 = {all, <<"v0.4.0">>}, + E2 = {all, <<"v0.5.0">>}, + {Plats, Since} = spectrometer_updater:merge_entry(E1, E2), + ?assertEqual(all, Plats), + ?assertEqual(<<"v0.4.0">>, Since) + end}. + +merge_entry_list_platforms_test_() -> + {"merges platform lists", fun() -> + E1 = {[esp32], <<"v0.4.0">>}, + E2 = {[rp2], <<"v0.5.0">>}, + {Plats, Since} = spectrometer_updater:merge_entry(E1, E2), + ?assertEqual([esp32, rp2], Plats), + ?assertEqual(<<"v0.4.0">>, Since) + end}. + +merge_entry_all_with_list_test_() -> + {"all merged with list stays all", fun() -> + E1 = {all, <<"v0.4.0">>}, + E2 = {[esp32], <<"v0.5.0">>}, + {Plats, Since} = spectrometer_updater:merge_entry(E1, E2), + ?assertEqual(all, Plats), + ?assertEqual(<<"v0.4.0">>, Since) + end}. + +%% ============================================================================= +%% merge_since/2 tests +%% ============================================================================= + +merge_since_two_tags_test_() -> + {"two tags: older wins", fun() -> + ?assertEqual( + <<"v0.4.0">>, + spectrometer_updater:merge_since(<<"v0.4.0">>, <<"v0.5.0">>) + ), + ?assertEqual( + <<"v0.4.0">>, + spectrometer_updater:merge_since(<<"v0.5.0">>, <<"v0.4.0">>) + ) + end}. + +merge_since_tag_vs_unreleased_test_() -> + {"tag vs unreleased: tag wins", fun() -> + ?assertEqual( + <<"v0.5.0">>, + spectrometer_updater:merge_since( + <<"v0.5.0">>, {unreleased, <<"main">>} + ) + ), + ?assertEqual( + <<"v0.5.0">>, + spectrometer_updater:merge_since( + {unreleased, <<"main">>}, <<"v0.5.0">> + ) + ) + end}. + +merge_since_both_unreleased_test_() -> + {"two unreleased: lexicographically first wins", fun() -> + ?assertEqual( + {unreleased, <<"0.6.x">>}, + spectrometer_updater:merge_since( + {unreleased, <<"0.6.x">>}, {unreleased, <<"0.7.x">>} + ) + ), + ?assertEqual( + {unreleased, <<"0.6.x">>}, + spectrometer_updater:merge_since( + {unreleased, <<"0.7.x">>}, {unreleased, <<"0.6.x">>} + ) + ), + ?assertEqual( + {unreleased, <<"0.7.x">>}, + spectrometer_updater:merge_since( + {unreleased, <<"0.7.x">>}, {unreleased, <<"main">>} + ) + ), + ?assertEqual( + {unreleased, <<"0.7.x">>}, + spectrometer_updater:merge_since( + {unreleased, <<"main">>}, {unreleased, <<"0.7.x">>} + ) + ) + end}. + +merge_since_fallback_test_() -> + {"fallback keeps existing", fun() -> + ?assertEqual( + all, spectrometer_updater:merge_since(all, something_else) + ) + end}. + +%% ============================================================================= +%% normalize_platform_name/1 tests +%% ============================================================================= + +normalize_platform_name_variants_test_() -> + {"normalizes all platform name variants", fun() -> + ?assertEqual( + rp2, spectrometer_utils:normalize_platform_name("rp2") + ), + ?assertEqual( + rp2, spectrometer_utils:normalize_platform_name("RP2") + ), + ?assertEqual( + rp2, spectrometer_utils:normalize_platform_name("rp2040") + ), + ?assertEqual( + rp2, spectrometer_utils:normalize_platform_name("RP2040") + ), + ?assertEqual( + esp32, spectrometer_utils:normalize_platform_name("esp32") + ), + ?assertEqual( + esp32, spectrometer_utils:normalize_platform_name("ESP32") + ), + ?assertEqual( + stm32, spectrometer_utils:normalize_platform_name("stm32") + ), + ?assertEqual( + stm32, spectrometer_utils:normalize_platform_name("STM32") + ), + ?assertEqual( + emscripten, + spectrometer_utils:normalize_platform_name("emscripten") + ), + ?assertEqual( + emscripten, + spectrometer_utils:normalize_platform_name("Emscripten") + ), + ?assertEqual( + generic_unix, + spectrometer_utils:normalize_platform_name("generic_unix") + ), + ?assertEqual( + generic_unix, + spectrometer_utils:normalize_platform_name("GenericUnix") + ), + ?assertEqual( + {error, badarg}, + spectrometer_utils:normalize_platform_name("custom_plat") + ) + end}. + +%% ============================================================================= +%% write_db_file/2 tests +%% ============================================================================= + +write_db_file_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("updater_test_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + Acc = #{ + {lists, map, 2} => {all, {unreleased, <<"main">>}}, + {io, format, 2} => {all, {unreleased, <<"main">>}} + }, + Path = filename:join(Dir, "test.data"), + ok = spectrometer_updater:write_db_file(Path, Acc), + ?assert(filelib:is_file(Path)), + %% Verify it can be read back + {ok, [Data]} = file:consult(Path), + ?assert(is_list(Data)) + end) + end, + fun(Dir) -> + ?_test(begin + Acc = #{ + {lists, map, 2} => {all, {unreleased, <<"main">>}}, + {esp32_module, func, 1} => { + [esp32], {unreleased, <<"main">>} + } + }, + Path = filename:join(Dir, "test_platforms.data"), + ok = spectrometer_updater:write_db_file(Path, Acc), + {ok, [Data]} = file:consult(Path), + %% Check structure: {module, [{func, arity, platforms, since}]} + ?assert(is_list(Data)), + %% Each entry should be {Module, [{Func, Arity, Platforms, Since}]} + lists:foreach( + fun({Mod, Funs}) -> + ?assert(is_atom(Mod)), + ?assert(is_list(Funs)), + lists:foreach( + fun({F, A, P, _S}) -> + ?assert(is_atom(F)), + ?assert(is_integer(A)), + ?assert(P =:= all orelse is_list(P)) + end, + Funs + ) + end, + Data + ) + end) + end, + fun(Dir) -> + ?_test(begin + Acc = #{ + {module1, func1, 1} => {all, {unreleased, <<"main">>}}, + {module2, func2, 2} => { + [esp32, rp2], {unreleased, <<"main">>} + } + }, + Path = filename:join(Dir, "roundtrip.data"), + ok = spectrometer_updater:write_db_file(Path, Acc), + %% Read back and verify + {ok, [Data]} = file:consult(Path), + FlatList = [ + {M, F, A, P} + || {M, Funs} <- Data, {F, A, P, _S} <- Funs + ], + ?assert(length(FlatList) =:= 2) + end) + end + ]} + }. + +%% ============================================================================= +%% Integration tests with fake AtomVM repo structure +%% ============================================================================= + +scan_repo_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("updater_repo_test_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(_RepoDir) -> + ?_test(begin + % Create fresh repo for each test case + RepoDir = spectrometer_utils:make_temp_dir( + "updater_repo_test_gperf_" + ), + ok = filelib:ensure_path(RepoDir), + try + % Create minimal structure with just gperf files + LibDir = filename:join(RepoDir, "src/libAtomVM"), + ok = filelib:ensure_path(LibDir), + + % Create bifs.gperf + BifsContent = + "/* Some comment */\n" ++ + "extern int some_c_function();\n" ++ + "\n" ++ + "%%\n" ++ + "erlang:abs/1, bif_erlang_abs_1, true\n" ++ + "\n", + ok = file:write_file( + filename:join(LibDir, "bifs.gperf"), BifsContent + ), + + % Create nifs.gperf + NifsContent = + "/* Some comment */\n" ++ + "\n" ++ + "%%\n" ++ + "binary:at/2, &binary_at_nif\n" ++ + "\n", + ok = file:write_file( + filename:join(LibDir, "nifs.gperf"), NifsContent + ), + + Acc = spectrometer_updater:scan_atomvm_repo( + RepoDir, #{tests => false}, {unreleased, <<"main">>} + ), + ?assert(is_map(Acc)), + ?assert(maps:is_key({erlang, abs, 1}, Acc)), + ?assert(maps:is_key({binary, at, 2}, Acc)), + ?assertEqual( + {all, {unreleased, <<"main">>}}, + maps:get({erlang, abs, 1}, Acc) + ), + ?assertEqual( + {all, {unreleased, <<"main">>}}, + maps:get({binary, at, 2}, Acc) + ) + after + spectrometer_utils:purge_dir(RepoDir) + end + end) + end, + fun(_RepoDir) -> + ?_test(begin + RepoDir = spectrometer_utils:make_temp_dir( + "updater_repo_test_libs_" + ), + ok = filelib:ensure_path(RepoDir), + try + % Create libs structure + LibSrcDir = filename:join(RepoDir, "libs/estdlib/src"), + ok = filelib:ensure_path(LibSrcDir), + + LibSource = + "-module(my_lists).\n" ++ + "-export([map/2, filter/2]).\n" ++ + "\n" ++ + "map(F, []) -> [];\n" ++ + "map(F, [H|T]) -> [F(H) | map(F, T)].\n" ++ + "\n" ++ + "filter(P, []) -> [];\n" ++ + "filter(P, [H|T]) ->\n" ++ + " case P(H) of\n" ++ + " true -> [H | filter(P, T)];\n" ++ + " false -> filter(P, T)\n" ++ + " end.\n", + ok = file:write_file( + filename:join(LibSrcDir, "my_lists.erl"), LibSource + ), + + Acc = spectrometer_updater:scan_atomvm_repo( + RepoDir, #{tests => false}, {unreleased, <<"main">>} + ), + % The scanner should find my_lists:map/2 and my_lists:filter/2 + ?assert(is_map(Acc)), + ?assert( + maps:size(Acc) > 0, + "Expected scanner to find entries from estdlib" + ), + ?assert( + maps:is_key({my_lists, map, 2}, Acc), + "Expected to find my_lists:map/2 in scan results" + ), + ?assert( + maps:is_key({my_lists, filter, 2}, Acc), + "Expected to find my_lists:filter/2 in scan results" + ) + after + spectrometer_utils:purge_dir(RepoDir) + end + end) + end, + fun(_RepoDir) -> + ?_test(begin + RepoDir = spectrometer_utils:make_temp_dir( + "updater_repo_test_empty_" + ), + ok = filelib:ensure_path(RepoDir), + try + % Create minimal structure + LibDir = filename:join(RepoDir, "src/libAtomVM"), + ok = filelib:ensure_path(LibDir), + + % Create empty gperf files + ok = file:write_file( + filename:join(LibDir, "bifs.gperf"), "{}\n" + ), + ok = file:write_file( + filename:join(LibDir, "nifs.gperf"), "{}\n" + ), + + % Create tests directory (should be ignored) + TestsDir = filename:join(RepoDir, "tests/erlang_tests"), + ok = filelib:ensure_path(TestsDir), + + Acc = spectrometer_updater:scan_atomvm_repo( + RepoDir, #{tests => false}, {unreleased, <<"main">>} + ), + ?assert(is_map(Acc)) + after + spectrometer_utils:purge_dir(RepoDir) + end + end) + end, + fun(_RepoDir) -> + ?_test(begin + RepoDir = spectrometer_utils:make_temp_dir( + "updater_repo_test_clean_" + ), + ok = filelib:ensure_path(RepoDir), + try + % Empty repo + Acc = spectrometer_updater:scan_atomvm_repo( + RepoDir, #{tests => false}, {unreleased, <<"main">>} + ), + ?assert(is_map(Acc)), + ?assertEqual(0, maps:size(Acc)) + after + spectrometer_utils:purge_dir(RepoDir) + end + end) + end + ]} + }. + +%% ============================================================================= +%% scan_calls via AST tests +%% ============================================================================= + +scan_via_ast_test_() -> + { + setup, + fun() -> + Dir = spectrometer_utils:make_temp_dir("ast_scan_test_"), + ok = filelib:ensure_path(Dir), + Dir + end, + fun spectrometer_utils:purge_dir/1, + {with, [ + fun(Dir) -> + ?_test(begin + TestFile = filename:join(Dir, "test_mod.erl"), + Content = << + "-module(test_mod).\n" + "-export([test/0]).\n" + "test() ->\n" + " lists:map(fun(X) -> X * 2 end, [1,2,3]),\n" + " io:format(\"hello\"),\n" + " ok.\n" + >>, + ok = file:write_file(TestFile, Content), + {ok, test_mod, Calls} = spectrometer_scanner:parse_calls( + TestFile + ), + ?assertEqual(test_mod, test_mod), + ?assert(is_map(Calls)), + % lists:map/2 should be found + ?assert(maps:is_key({lists, map, 2}, Calls)), + % io:format/1 should be found + ?assert(maps:is_key({io, format, 1}, Calls)) + end) + end, + fun(Dir) -> + ?_test(begin + TestFile = filename:join(Dir, "test_mod.erl"), + Content = << + "-module(test_mod).\n" + "-export([test/0]).\n" + "test() ->\n" + " test_mod:internal(),\n" + " ok.\n" + "\n" + "internal() ->\n" + " lists:map(fun(X) -> X end, [1]).\n" + >>, + ok = file:write_file(TestFile, Content), + {ok, test_mod, Calls} = spectrometer_scanner:parse_calls( + TestFile + ), + ?assert(is_map(Calls)), + % Self-call test_mod:internal/0 should NOT be in calls + ?assertNot(maps:is_key({test_mod, internal, 0}, Calls)), + % lists:map/2 should be found + ?assert(maps:is_key({lists, map, 2}, Calls)) + end) + end + ]} + }. diff --git a/test/spectrometer_utils_tests.erl b/test/spectrometer_utils_tests.erl new file mode 100644 index 0000000..7c90507 --- /dev/null +++ b/test/spectrometer_utils_tests.erl @@ -0,0 +1,253 @@ +%% +%% 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_utils_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ============================================================================= +%% normalize_github_url/1 tests +%% ============================================================================= + +normalize_github_url_test_() -> + [ + {"fixes http:// prefix", + ?_assertEqual( + "https://github.com/user/repo.git", + spectrometer_utils:normalize_github_url( + "http://github.com/user/repo.git" + ) + )}, + + {"adds .git suffix", + ?_assertEqual( + "https://github.com/user/repo.git", + spectrometer_utils:normalize_github_url( + "https://github.com/user/repo" + ) + )}, + + {"handles short user/repo names", + ?_assertEqual( + "https://github.com/user/repo.git", + spectrometer_utils:normalize_github_url( + "user/repo" + ) + )}, + + {"handles whitespace", + ?_assertEqual( + "https://github.com/user/repo.git", + spectrometer_utils:normalize_github_url( + " user/repo\n" + ) + )}, + + {"removes multiple trailing slashes", + ?_assertEqual( + "https://github.com/user/repo.git", + spectrometer_utils:normalize_github_url( + "https://github.com/user/repo///" + ) + )}, + + {"handles .git with trailing slash", + ?_assertEqual( + "https://github.com/user/repo.git", + spectrometer_utils:normalize_github_url( + "https://github.com/user/repo.git/" + ) + )}, + + {"lowercases the URL", + ?_assertEqual( + "https://github.com/user/repo.git", + spectrometer_utils:normalize_github_url( + "https://GitHub.com/User/Repo" + ) + )}, + + {"handles full URL with all modifications", + ?_assertEqual( + "https://github.com/user/repo.git", + spectrometer_utils:normalize_github_url( + "http://GitHub.com/User/Repo.git/" + ) + )}, + + {"handles plain github.com URL", + ?_assertEqual( + "https://github.com/user/repo.git", + spectrometer_utils:normalize_github_url( + "github.com/user/repo" + ) + )}, + + {"handles organization repo", + ?_assertEqual( + "https://github.com/atomvm/atomvm.git", + spectrometer_utils:normalize_github_url( + "atomvm/AtomVM.git" + ) + )}, + + {"handles short path with trailing slash", + ?_assertEqual( + "https://github.com/atomvm/atomvm.git", + spectrometer_utils:normalize_github_url( + "AtomVM/AtomVM/" + ) + )} + ]. + +%% ============================================================================= +%% make_temp_dir/1 tests +%% ============================================================================= + +make_temp_dir_prefix_test_() -> + {"creates directory with prefix", fun() -> + Dir = spectrometer_utils:make_temp_dir("test_"), + try + ?assert(filelib:is_dir(Dir)), + ?assert(string:prefix(filename:basename(Dir), "test_") =/= nomatch) + after + spectrometer_utils:purge_dir(Dir) + end + end}. + +make_temp_dir_unique_test_() -> + {"creates unique directories", fun() -> + Dir1 = spectrometer_utils:make_temp_dir("test_"), + Dir2 = spectrometer_utils:make_temp_dir("test_"), + try + ?assert(filelib:is_dir(Dir1)), + ?assert(filelib:is_dir(Dir2)), + ?assertNot(Dir1 =:= Dir2) + after + spectrometer_utils:purge_dir(Dir1), + spectrometer_utils:purge_dir(Dir2) + end + end}. + +make_temp_dir_writable_test_() -> + {"directory is writable", fun() -> + Dir = spectrometer_utils:make_temp_dir("write_test_"), + try + TestFile = filename:join(Dir, "test.txt"), + ok = file:write_file(TestFile, "hello"), + {ok, Content} = file:read_file(TestFile), + ?assertEqual(<<"hello">>, Content) + after + spectrometer_utils:purge_dir(Dir) + end + end}. + +make_temp_dir_nested_test_() -> + {"creates nested subdirectories", fun() -> + Dir = spectrometer_utils:make_temp_dir("nested_test_"), + try + SubDir = filename:join(Dir, "sub/dir"), + ok = filelib:ensure_path(SubDir), + ?assert(filelib:is_dir(SubDir)), + TestFile = filename:join(SubDir, "test.txt"), + ok = file:write_file(TestFile, "nested"), + ?assert(filelib:is_file(TestFile)) + after + spectrometer_utils:purge_dir(Dir) + end + end}. + +%% ============================================================================= +%% purge_dir/1 tests +%% ============================================================================= + +purge_dir_with_files_test_() -> + {"removes directory with files", fun() -> + Dir = spectrometer_utils:make_temp_dir("purge_dir_test_"), + try + File1 = filename:join(Dir, "file1.txt"), + SubDir = filename:join(Dir, "subdir"), + File2 = filename:join(SubDir, "file2.txt"), + ok = filelib:ensure_path(SubDir), + ok = file:write_file(File1, "content1"), + ok = file:write_file(File2, "content2"), + ?assert(filelib:is_file(File1)), + ?assert(filelib:is_file(File2)), + ?assert(filelib:is_dir(SubDir)), + spectrometer_utils:purge_dir(Dir), + ?assertNot(filelib:is_dir(Dir)), + ?assertNot(filelib:is_file(File1)), + ?assertNot(filelib:is_file(File2)) + after + case filelib:is_dir(Dir) of + true -> spectrometer_utils:purge_dir(Dir); + false -> ok + end + end + end}. + +purge_dir_idempotent_test_() -> + {"handles non-existent directory gracefully", fun() -> + Dir = spectrometer_utils:make_temp_dir("purge_dir_test2_"), + try + spectrometer_utils:purge_dir(Dir), + %% Should not crash when called again on already removed dir + spectrometer_utils:purge_dir(Dir), + ?assertNot(filelib:is_dir(Dir)) + after + case filelib:is_dir(Dir) of + true -> spectrometer_utils:purge_dir(Dir); + false -> ok + end + end + end}. + +purge_dir_nested_test_() -> + {"removes deeply nested structure", fun() -> + Dir = spectrometer_utils:make_temp_dir("purge_dir_test3_"), + try + DeepDir = filename:join(Dir, "a/b/c/d"), + DeepFile = filename:join(DeepDir, "deep.txt"), + ok = filelib:ensure_path(DeepDir), + ok = file:write_file(DeepFile, "deep"), + ?assert(filelib:is_file(DeepFile)), + spectrometer_utils:purge_dir(Dir), + ?assertNot(filelib:is_dir(Dir)) + after + case filelib:is_dir(Dir) of + true -> spectrometer_utils:purge_dir(Dir); + false -> ok + end + end + end}. + +%% ============================================================================= +%% spectrometer_http:fetch/1 tests +%% ============================================================================= + +http_get_test_() -> + case os:getenv("SKIP_NETWORK_TESTS") of + false -> + [ + {"returns error for invalid URL", + ?_assertMatch( + {error, _}, + spectrometer_http:fetch( + "http://this-domain-definitely-does-not-exist-12345.com" + ) + )}, + + {"returns error for non-existent path on localhost", + ?_assertMatch( + {error, _}, + spectrometer_http:fetch("http://localhost:59999/test") + )} + ]; + _ -> + [{"skipped (network tests disabled)", fun() -> ok end}] + end. From d6883bbea3ed97d8fdcb6be9dd0a5eaf08d9be80 Mon Sep 17 00:00:00 2001 From: Winford Date: Wed, 6 May 2026 20:00:06 -0700 Subject: [PATCH 2/2] Add CI workflows Signed-off-by: Winford --- .github/workflows/build-and-test.yaml | 78 ++++ .github/workflows/check-formatting.yaml | 100 +++++ .github/workflows/code_quality_check.yaml | 55 +++ .github/workflows/publish_docs.yml | 75 ++++ .github/workflows/reuse-lint.yaml | 27 ++ .markdownlint.json | 5 + .markdownlint.json.license | 2 + LICENSES/Apache-2.0.txt | 2 +- LICENSES/CC0-1.0.txt | 121 ++++++ LICENSES/LGPL-2.1-or-later.txt | 502 ++++++++++++++++++++++ priv/supported_functions.data | 3 - priv/supported_functions.data.license | 7 + 12 files changed, 973 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/build-and-test.yaml create mode 100644 .github/workflows/check-formatting.yaml create mode 100644 .github/workflows/code_quality_check.yaml create mode 100644 .github/workflows/publish_docs.yml create mode 100644 .github/workflows/reuse-lint.yaml create mode 100644 .markdownlint.json create mode 100644 .markdownlint.json.license create mode 100644 LICENSES/CC0-1.0.txt create mode 100644 LICENSES/LGPL-2.1-or-later.txt create mode 100644 priv/supported_functions.data.license diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml new file mode 100644 index 0000000..6eb5783 --- /dev/null +++ b/.github/workflows/build-and-test.yaml @@ -0,0 +1,78 @@ +# +# Copyright 2025 Winford (Uncle Grumpy) +# +# SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +# + +name: Build and Test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref != 'refs/heads/main' && github.ref || github.run_id }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: "ubuntu-24.04" + strategy: + fail-fast: false + matrix: + otp: ["27", "28", "master"] + include: + - otp: "27" + rebar3: "3.25.1" + - otp: "28" + rebar3: "3.26.0" + - otp: "29" + rebar3: "3.27.0" + - otp: "master" + rebar3: "3.27.0" + permissions: + contents: read + steps: + + - name: "Setup BEAM" + id: beam-setup + uses: erlef/setup-beam@e6d7c94229049569db56a7ad5a540c051a010af9 + with: + otp-version: ${{ matrix.otp }} + rebar3-version: ${{matrix.rebar3}} + + - name: "System info" + run: | + echo "**uname:**" + uname -a + echo "**OTP version:**" + cat "$(dirname "$(which erlc)")/../releases/RELEASES" || true + + - name: "Checkout repo" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: "Restore rebar3 dialyzer and test-coverage cache" + id: test-cover_cache + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 + env: + cache-name: rebar3 + with: + path: | + _build + key: ci-${{runner.os}}-${{env.cache-name}}-otp_${{matrix.otp}}-rebar_${{matrix.rebar3}}-${{hashFiles('rebar.config', 'rebar.lock')}} + + # Build + - name: "Build escripts" + run: | + rebar3 escriptize + + - name: "Build docs" + run: | + rebar3 as doc ex_doc + + - name: "Run Tests" + run: | + rebar3 eunit + rebar3 cover diff --git a/.github/workflows/check-formatting.yaml b/.github/workflows/check-formatting.yaml new file mode 100644 index 0000000..46b25da --- /dev/null +++ b/.github/workflows/check-formatting.yaml @@ -0,0 +1,100 @@ +# +# Copyright 2025 Winford (Uncle Grumpy) +# +# SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +# + +name: "Check Formatting" + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - '.github/workflows/**' + - 'rebar.config' + - 'priv/*' + - 'src/**' + - 'include/**' + - 'test/**' + - '**/*.erl' + - '**/*.hrl' + pull_request: + paths: + - '.github/workflows/**' + - 'rebar.config' + - 'priv/*' + - 'src/**' + - 'include/**' + - 'test/**' + - '**/*.erl' + - '**/*.hrl' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref != 'refs/heads/main' && github.ref || github.run_id }} + cancel-in-progress: true + +jobs: + format-check: + runs-on: ubuntu-24.04 + env: + ERLFMT_VERSION: "v1.7.0" + ACTIONLINT_VERSION: "v1.7.10" + permissions: + contents: read + steps: + + - name: "Setup BEAM" + uses: erlef/setup-beam@e6d7c94229049569db56a7ad5a540c051a010af9 + id: otp + with: + otp-version: "28" + rebar3-version: "3.26.0" + + - name: "Checkout code" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: "Cache: restore tools" + id: cache-tools + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 + env: + cache-name: format-checkers + with: + path: | + ~/go + ~/erlfmt + key: ci-${{runner.os}}-${{env.cache-name}}-${{env.ACTIONLINT_VERSION}}-otp_${{steps.otp.outputs.otp-version}}-rebar3_${{steps.otp.outputs.rebar3-version}}-erlfmt_${{env.ERLFMT_VERSION}} + + - name: "Install Actionlint" + if: steps.cache-tools.outputs.cache-hit != 'true' + run: | + cd "${HOME}" + go install "github.com/rhysd/actionlint/cmd/actionlint@${{env.ACTIONLINT_VERSION}}" + + - name: "Install erlfmt" + if: steps.cache-tools.outputs.cache-hit != 'true' + run: | + cd "${HOME}" + git clone --depth 1 -b "${ERLFMT_VERSION}" https://github.com/WhatsApp/erlfmt.git + cd erlfmt + rebar3 as release escriptize + + - name: "Add tools to PATH" + run: | + echo "${HOME}/go/bin" >> "$GITHUB_PATH" + echo "${HOME}/erlfmt/_build/release/bin" >> "$GITHUB_PATH" + + - name: "Check formatting" + run: | + rebar3 fmt --check + + - name: "Check workflows" + run: actionlint + + - name: "Check markdown" + uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd + with: + config: '.markdownlint.json' + globs: | + **/*.md + !_build/** diff --git a/.github/workflows/code_quality_check.yaml b/.github/workflows/code_quality_check.yaml new file mode 100644 index 0000000..90d1660 --- /dev/null +++ b/.github/workflows/code_quality_check.yaml @@ -0,0 +1,55 @@ +# +# Copyright 2025 Winford (Uncle Grumpy) +# +# SPDX-License-Identifier: Apache-2.0 +# + +name: Code Quality Checks + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref != 'refs/heads/main' && github.ref || github.run_id }} + cancel-in-progress: true + +jobs: + code-quality: + runs-on: "ubuntu-24.04" + env: + OTP_VERSION: "28" + REBAR3_VERSION: "3.26.0" + permissions: + contents: read + steps: + + - name: "Setup BEAM" + uses: erlef/setup-beam@e6d7c94229049569db56a7ad5a540c051a010af9 + id: "beam" + with: + otp-version: ${{env.OTP_VERSION}} + rebar3-version: ${{env.REBAR3_VERSION}} + + - name: "Checkout repo" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: "Restore rebar3 cache (speed up dialyzer)" + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 + env: + cache-name: rebar3 + with: + path: | + ~/.cache/rebar3 + _build + key: ci-${{runner.os}}-${{env.cache-name}}-otp_${{env.OTP_VERSION}}-rebar_${{env.REBAR3_VERSION}}-${{hashFiles('rebar.config')}} + + # xref + - name: "Check with xref" + run: rebar3 xref + + # dialyzer + - name: "Check with dialyzer" + run: rebar3 dialyzer diff --git a/.github/workflows/publish_docs.yml b/.github/workflows/publish_docs.yml new file mode 100644 index 0000000..95551e0 --- /dev/null +++ b/.github/workflows/publish_docs.yml @@ -0,0 +1,75 @@ +# +# Copyright 2026 Winford (Uncle Grumpy) +# +# SPDX-License-Identifier: Apache-2.0 +# +# This is a workflow for UncleGrumpy/atomvm_spectrometer to publish documentation to GitHub Pages + +name: Publish Docs + +on: + # Triggers the workflow on pushes to main + push: + branches: + - 'main' + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref != 'refs/heads/main' && github.ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + pages: write + id-token: write + +env: + LANG: C.UTF-8 + +jobs: + + build: + runs-on: ubuntu-24.04 + container: erlang:28 + steps: + + - name: "Checkout code" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: "Setup Pages" + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b + + - name: "Build Docs" + run: | + rebar3 as doc ex_doc + + - name: Upload pages artifact + ## Must use v3 for now due to issue actions/deploy-pages#389 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b + with: + name: github-pages + path: ./doc + + deploy: + # Add a dependency to the build job + needs: build + + # Deploy to the github-pages environment + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + # Specify runner + deployment step + runs-on: ubuntu-24.04 + steps: + + - name: "Setup Pages" + if: ${{ github.repository == 'UncleGrumpy/atomvm_spectrometer' }} + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b + + - name: Deploy to GitHub Pages + if: ${{ github.repository == 'UncleGrumpy/atomvm_spectrometer' }} + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e diff --git a/.github/workflows/reuse-lint.yaml b/.github/workflows/reuse-lint.yaml new file mode 100644 index 0000000..4f5d7d4 --- /dev/null +++ b/.github/workflows/reuse-lint.yaml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2022 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: CC0-1.0 + +name: REUSE Compliance Check + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref != 'refs/heads/main' && github.ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + name: "Reuse Compliance" + runs-on: ubuntu-24.04 + permissions: + contents: read + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: REUSE Compliance Check + uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..a610750 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "line-length": { + "tables": false + } +} \ No newline at end of file diff --git a/.markdownlint.json.license b/.markdownlint.json.license new file mode 100644 index 0000000..ebdd28d --- /dev/null +++ b/.markdownlint.json.license @@ -0,0 +1,2 @@ +Copyright 2026 Winford (Uncle Grumpy) +SPDX-License-Identifier: CC0-1.0 diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt index 33409de..0dd4183 100644 --- a/LICENSES/Apache-2.0.txt +++ b/LICENSES/Apache-2.0.txt @@ -168,4 +168,4 @@ agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. -END OF TERMS AND CONDITIONS \ No newline at end of file +END OF TERMS AND CONDITIONS diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/LICENSES/LGPL-2.1-or-later.txt b/LICENSES/LGPL-2.1-or-later.txt new file mode 100644 index 0000000..4362b49 --- /dev/null +++ b/LICENSES/LGPL-2.1-or-later.txt @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/priv/supported_functions.data b/priv/supported_functions.data index f52dee9..26aa4ab 100644 --- a/priv/supported_functions.data +++ b/priv/supported_functions.data @@ -2,9 +2,6 @@ %% Format: [{module, [{function, arity, platforms, since}]}] %% Platforms: 'all' or list of platform atoms [esp32, stm32, rp2, emscripten, generic_unix] %% Since: binary version string like <<"v0.5.0">> or {unreleased, <<"0.7.x">>} -%% -%% SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) -%% SPDX-License-Identifier: Apache-2.0 [ {ahttp_client, [ diff --git a/priv/supported_functions.data.license b/priv/supported_functions.data.license new file mode 100644 index 0000000..acf6d62 --- /dev/null +++ b/priv/supported_functions.data.license @@ -0,0 +1,7 @@ +%% +%% Copyright (c) 2026 Winford (UncleGrumpy) +%% +%% This is part of atomvm_spectrometer +%% +SPDX-FileCopyrightText: 2026 Winford (UncleGrumpy) +SPDX-License-Identifier: Apache-2.0