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/.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/.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 new file mode 100644 index 0000000..0dd4183 --- /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 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/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..26aa4ab --- /dev/null +++ b/priv/supported_functions.data @@ -0,0 +1,2409 @@ +%% 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">>} + +[ + {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/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 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.