Repository: giacomocavalieri/birdie Branch: main Commit: 1404298b945d Files: 34 Total size: 153.5 KB Directory structure: gitextract_pg72u7m9/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── setup.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── birdie.tape ├── gleam.toml ├── manifest.toml ├── src/ │ ├── birdie/ │ │ └── internal/ │ │ ├── analyser.gleam │ │ ├── cli.gleam │ │ ├── diagnostic.gleam │ │ ├── diff.gleam │ │ ├── project.gleam │ │ └── version.gleam │ ├── birdie.gleam │ ├── birdie_ffi.erl │ └── birdie_ffi.mjs └── test/ ├── birdie_snapshots/ │ ├── a_result_test.accepted │ ├── complex_function_test.accepted │ ├── hello_birdie_test.accepted │ ├── list_test.accepted │ ├── multi_line_diagnostic.accepted │ ├── single_line_diagnostic_with_no_tooltip_text.accepted │ ├── single_line_diagnostic_with_secondary_label_above.accepted │ ├── single_line_diagnostic_with_secondary_label_below.accepted │ ├── single_line_diagnostic_with_secondary_label_on_same_line,_secondary_label_is_ignored.accepted │ ├── single_line_diagnostic_with_secondary_label_right_above.accepted │ ├── single_line_diagnostic_with_secondary_label_right_below.accepted │ └── single_line_diagnostic_with_tooltip_text.accepted ├── birdie_test/ │ ├── cli_test.gleam │ └── diagnostic_test.gleam └── birdie_test.gleam ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: [giacomocavalieri] ================================================ FILE: .github/workflows/setup.yml ================================================ name: shared on: workflow_call: jobs: setup: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: otp-version: "28" gleam-version: "1.16.0" rebar3-version: "3" ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: - main pull_request: jobs: format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: otp-version: "28" gleam-version: "1.16.0" rebar3-version: "3" - run: gleam format --check src test test-erl: runs-on: ubuntu-latest strategy: matrix: otp-version: [27, 28] steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: otp-version: ${{ matrix.otp-version }} gleam-version: "1.16.0" rebar3-version: "3" - run: gleam test --target=erl stale-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: otp-version: "28" gleam-version: "1.16.0" rebar3-version: "3" - run: gleam test --target=erl - run: gleam run -m birdie stale check test-node: runs-on: ubuntu-latest strategy: matrix: node-version: ["latest"] steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: otp-version: "28" gleam-version: "1.16.0" rebar3-version: "3" - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - run: gleam test --target=js --runtime=node test-deno: runs-on: ubuntu-latest strategy: matrix: deno-version: ["latest", "lts"] steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: otp-version: "28" gleam-version: "1.16.0" rebar3-version: "3" - uses: denoland/setup-deno@v2 with: deno-version: ${{ matrix.deno-version }} - run: gleam test --target=js --runtime=deno test-bun: runs-on: ubuntu-latest strategy: matrix: bun-version: ["latest"] steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: otp-version: "28" gleam-version: "1.16.0" rebar3-version: "3" - uses: oven-sh/setup-bun@v2 with: bun-version: ${{ matrix.bun-version }} - run: gleam test --target=js --runtime=bun ================================================ FILE: .gitignore ================================================ *.beam *.ez /build erl_crash.dump ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## 2.0.0 - 2026-05-03 - Birdie snapshots are now located under the `test/birdie_snapshots` directory. When running a `gleam run -m birdie` for the first time in an old project, birdie will automatically move any existing snapshot. - The look of birdie's error messages has been improved. ## 1.5.5 - 2026-04-18 - Updated the `gleam_stdlib` constraint to allow using `v1.0.0`! ## 1.5.4 - 2026-01-28 - Improved the error message birdie produces when it can't open the temporary file due to not having the right permission. ## 1.5.3 - 2025-12-23 🎄 Happy Holidays! - Fixed a bug that inadvertently made it so the `birdie.snap` function no longer supported the JavaScript target. ## 1.5.2 - 2025-12-11 - Updated the `glance` dependency to `>= 6.0.0 and < 7.0.0`. ## 1.5.1 - 2025-12-11 - The CLI has been improved with better error messages and a nicer overall look. - Birdie now has better tooling to deal with stale snapshots. A stale snapshot is a snapshot that is no longer referenced by any snapshot test and so it could be safely removed. It's easy to end up having stale snapshots, especially when changing the name of a snapshot and forgetting to remove the olde one. The new `stale` command allows to: - `gleam run -m birdie stale check` to check if there's any stale snapshot - `gleam run -m birdie stale delete` to remove any stale snapshot ## 1.4.1 - 2025-09-28 - Updated the `edit_distance` dependency to `>= 3.0.0 and < 4.0.0`. ## 1.4.0 - 2025-08-20 - Birdie will generate valid snapshot files by adding an empty line at the end of each. ## 1.3.2 - 2025-07-28 - Stop using deprecated `int.digits` from `gleam_stdlib`. ## 1.3.1 - 2025-05-30 - Updated the `glance` dependency to `>= 5.0.0 and < 6.0.0`. ## 1.3.0 - 2025-05-16 - When reviewing snapshots with birdie there's now an option to hide the diff view. - When running `gleam run -m birdie` birdie will now update all the accepted snapshots' info if they've been moved to a different module. ## 1.2.7 - 2025-04-27 - ⬆️ Update `glance` to `>= 2.0.0 and < 4.0.0`. ## 1.2.6 - 2025-02-16 - 🔥 Move ffi code to pure Gleam implementation. ## 1.2.5 - 2024-12-20 - ⬆️ Update `glance` to `>= 2.0.0 and < 3.0.0`. ## 1.2.4 - 2024-11-20 - ⬆️ Update `stdlib` to `>= 0.43.0 and < 1.0.0` and remove deprecated code. ## 1.2.3 - 2024-09-06 - 🐛 Fixed a bug where birdie would strip `\r\n` out of a snapshot content ## 1.2.2 - 2024-09-06 - 🐛 Fixed a bug where snapshot tests would fail on Windows ## v1.2.1 - 2024-08-20 - ⬆️ Update `stdlib` to `>= 0.40.0 and < 1.0.0` ## v1.2.0 - 2024-08-12 - ✨ Birdie can now suggest and run the correct command if it can tell you've made a typo ## v1.1.8 - 2024-05-28 - ⬆️ Update `stdlib` to `>= 0.39.0 and < 1.0.0` ## v1.1.7 - 2024-05-28 - ⬆️ Update `simplifile` to `>= 2.0.1 and < 3.0.0` ## v1.1.6 - 2024-05-28 - ⬆️ Change dependencies contraints ## v1.1.5 - 2024-05-10 - 🧑🏻‍💻 No longer fail the process review if Glance cannot parse a test module. Instead title data will simply be omitted. ## v1.1.4 - 2024-04-26 - 🐛 Ignore non Gleam files in the test directory ## v1.1.3 - 2024-04-20 - ⬆️ Update glance dependency ## v1.1.2 - 2024-04-11 - 🐛 Make sure no snapshot with an empty title is accepted ## v1.1.1 - 2024-04-07 - ⬆️ Update filepath from `~> 0.1` to `~> 1.0` - ➖ Drop `gap` dependency - ➖ Drop `gleeunit` dependency ## v1.1.0 - 2024-03-12 - ✨ Fail the review if there's tests with duplicate titles ## v1.0.4 - 2024-02-01 - ➖ Drop the `glam` dependency ## v1.0.3 - 2024-02-01 - 🧑🏻‍💻 Improve diffs look ## v1.0.2 - 2024-01-28 - 📝 Improve `birdie.main` documentation ## v1.0.1 - 2024-01-27 - 🐛 Fix a bug with js FFI code ## v1.0.0 - 2024-01-27 - 🎉 First release! ================================================ FILE: LICENSE ================================================ 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: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) 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 (d) 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 APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # 🐦‍⬛ Birdie - snapshot testing in Gleam [![Package Version](https://img.shields.io/hexpm/v/birdie)](https://hex.pm/packages/birdie) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/birdie/) Snapshot testing allows you to perform assertions without having to write the expectation yourself. Birdie will store a snapshot of the expected value and compare future runs of the same test against it. Imagine doing a `assert got == expected` where you don't have to take care of writing the expected output. > If you want a video introduction to snapshot testing, you can check out > [my talk on the topic](https://youtu.be/DpakV96jeRk?si=ODqE_geVaMIR0Qoa) > from Code BEAM Europe. ## Writing snapshot tests with Birdie First you'll want to add the package to your dependencies: ```sh gleam add --dev birdie ``` To write snapshot tests you can import the `birdie` module and use the [`snap`](https://hexdocs.pm/birdie/birdie.html#snap) function: ```gleam import gleeunit import birdie pub fn main() { gleeunit.main() } pub fn hello_birdie_test() { "🐦‍⬛ Smile for the birdie!" |> birdie.snap(title: "my first snapshot") // All snapshots must have a unique title! } ``` This will record a new snapshot with the given title and content. A snapshot test will always fail on its first run until you review and accept it. Once you've reviewed and accepted a snapshot, the test will fail only if the snapshot's content changes; in that case you will be presented with a diff and asked to review it once again. A typical workflow will look like this: - Run your tests - If you have any new snapshots - or some of the snapshots have changed - some tests will fail - Review all the new snapshots deciding if you want to keep the new version or the previously accepted one - And don't forget to commit your snapshots! Those should be treated like code and checked with the vcs you're using ## Reviewing snapshots Birdie also provides a CLI tool to help you in the review process: run `gleam run -m birdie` in your project and birdie will help you interactively review all your new snapshots. > The CLI tool can also do more than just guide you through all your snapshots > one by one. To check all the available options you can run > `gleam run -m birdie help` ![image](https://github.com/giacomocavalieri/birdie/blob/main/birdie.gif?raw=true) ## FAQ ### What should my snapshots be named like? A good idea is to give snapshots long descriptive titles that clearly state what you're expecting to see when reviewing those. Also all snapshots _must_ have unique names so that birdie won't mix those up, so be careful when naming snapshots to not repeat the same title twice! > During the review process, Birdie will try to be helpful and show you an > error message if it can spot two tests that happen to share the same exact > title. It will only work for snapshots that have a literal string as a title > but it can be really helpful to spot some of those confusing bugs! ### How big should the snapshot's content be? My recommendation is strive to have small and cohesive snapshots. Each snapshot test should test one thing and one thing only. Having small snapshots will make your life way easier during the review process! It's better to review 10 small snapshots than a single huge one and you'll see better, more focused diffs. ### Why is the snapshot content a `String`? I want to snapshot other things! Birdie will only ever accept `String` values and it's up to you to turn your own Gleam types into a `String` before snapping those: this way you have total freedom and will be able to choose a format that makes sense to you and makes things easier to review! The time spent making snapshots nicer is well worth the effort and will pay dividends as your test suite grows! You absolutely want to be intentional about the look of your snapshots, crafting one that makes it easy to review them. [I have an entire talk about this!](https://www.youtube.com/watch?v=DpakV96jeRk) ## References This package was heavily inspired by the excellent Rust library [`insta`](https://insta.rs), do check it out! ## Contributing If you think there's any way to improve this package, or if you spot a bug don't be afraid to open PRs, issues or requests of any kind! Any contribution is welcome 💜 ================================================ FILE: birdie.tape ================================================ Output birdie.gif Set Shell "bash" Set FontSize 24 Set Width 900 Set Height 700 Type "gleam run -m birdie" Sleep 500ms Enter Sleep 5s Type "s" Sleep 500ms Enter Sleep 5s Type "s" Sleep 500ms Enter Sleep 5s Type "s" Sleep 500ms Enter Sleep 5s ================================================ FILE: gleam.toml ================================================ name = "birdie" version = "2.0.0" description = "🐦‍⬛ Snapshot testing in Gleam" licences = ["Apache-2.0"] repository = { type = "github", user = "giacomocavalieri", repo = "birdie" } target = "erlang" [javascript.deno] allow_env = ["TMPDIR", "TEMP", "TMP"] allow_read = true allow_write = true [dependencies] argv = ">= 1.0.2 and < 2.0.0" trie_again = ">= 1.1.2 and < 2.0.0" edit_distance = ">= 3.0.0 and < 4.0.0" envoy = ">= 1.1.0 and < 2.0.0" filepath = ">= 1.1.0 and < 2.0.0" glance = ">= 6.0.0 and < 7.0.0" gleam_community_ansi = ">= 1.4.0 and < 2.0.0" gleam_json = ">= 3.0.0 and < 4.0.0" gleam_stdlib = ">= 0.43.0 and < 2.0.0" global_value = ">= 1.0.0 and < 2.0.0" justin = ">= 1.0.1 and < 2.0.0" rank = ">= 1.0.0 and < 2.0.0" simplifile = ">= 2.4.0 and < 3.0.0" term_size = ">= 1.0.1 and < 2.0.0" tom = ">= 2.0.0 and < 3.0.0" [dev-dependencies] gleeunit = ">= 1.2.0 and < 2.0.0" ================================================ FILE: manifest.toml ================================================ # This file was generated by Gleam # You typically do not need to edit this file packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "edit_distance", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "DE588BC3483ED6DBA717211B59F3178891A5FC6F1C00D415AE8C4233EAFB94B1" }, { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "glance", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "49E0ED4793BB3F56C3E5ED00528D70CAE21D263F70A735604124B95C5F62E2DB" }, { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" }, { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, { name = "gleam_stdlib", version = "0.71.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "702F3BC2A14793906880B1078B19A6165F87323AEE8D0C4A34085846336FCAAE" }, { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, { name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" }, { name = "global_value", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "global_value", source = "hex", outer_checksum = "23F74C91A7B819C43ABCCBF49DAD5BB8799D81F2A3736BA9A534BD47F309FF4F" }, { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, { name = "tom", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "234A842F3D087D35737483F5DFB6DE9839E3366EF0CAF8726D2D094210227670" }, { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, ] [requirements] argv = { version = ">= 1.0.2 and < 2.0.0" } edit_distance = { version = ">= 3.0.0 and < 4.0.0" } envoy = { version = ">= 1.1.0 and < 2.0.0" } filepath = { version = ">= 1.1.0 and < 2.0.0" } glance = { version = ">= 6.0.0 and < 7.0.0" } gleam_community_ansi = { version = ">= 1.4.0 and < 2.0.0" } gleam_json = { version = ">= 3.0.0 and < 4.0.0" } gleam_stdlib = { version = ">= 0.43.0 and < 2.0.0" } gleeunit = { version = ">= 1.2.0 and < 2.0.0" } global_value = { version = ">= 1.0.0 and < 2.0.0" } justin = { version = ">= 1.0.1 and < 2.0.0" } rank = { version = ">= 1.0.0 and < 2.0.0" } simplifile = { version = ">= 2.4.0 and < 3.0.0" } term_size = { version = ">= 1.0.1 and < 2.0.0" } tom = { version = ">= 2.0.0 and < 3.0.0" } trie_again = { version = ">= 1.1.2 and < 2.0.0" } ================================================ FILE: src/birdie/internal/analyser.gleam ================================================ import birdie/internal/diagnostic.{type Diagnostic} import glance.{type Span} import gleam/bool import gleam/dict.{type Dict} import gleam/list import gleam/option.{None, Some} import gleam/result import gleam/set.{type Set} import gleam/string import gleam/uri.{type Uri} pub opaque type Analyser { Analyser( /// A dict from module name to the titles inside that module. modules: Dict(Uri, AnalysedModule), /// A dictionary from snapshot literal title to a dictionary mapping from /// modules to the snapshots that it defines with that title. literal_titles: Dict(String, Dict(Uri, List(SnapshotTest))), ) } pub type Error { TitleAlreadyInUse( module: AnalysedModule, /// The span covering the function name where the snapshot test is defined. /// For example: /// /// ```gleam /// pub fn wibble() { /// // ^^^^^^ This! /// birdie.snap(...) /// } /// ``` /// test_function_name_span: Span, /// The span covering the title of the snapshot. /// For example: /// /// ```gleam /// pub fn wibble() { /// birdie.snap(todo, title: "hello") /// // ^^^^^^^ This! /// } /// ``` /// title_span: Span, ) } pub type Warning { NonLiteralTitle(module: AnalysedModule, title_span: Span) } pub type Module { Module(path: Uri, source: String) } pub type AnalysedModule { AnalysedModule(path: Uri, source: String, snapshots: List(SnapshotTest)) } pub type SnapshotTest { SnapshotTest( /// The title used for the snapshot. For example: /// /// ```gleam /// birdie.snap(todo, title: "wibble") /// // ^^^^^^^^ This is the title! /// ``` /// title: SnapshotTitle, /// The span covering just the title of the `birdie.snap` call. For example: /// /// ```gleam /// birdie.snap(todo, title: "wibble") /// // ^^^^^^^^ This! /// ``` /// title_span: Span, /// The span covering the whole `birdie.snap` call. For example: /// /// ```gleam /// birdie.snap(todo, todo) /// // ^^^^^^^^^^^^^^^^^^^^^^^ This! /// ``` /// /// With pipelines, it covers the entire pipeline! /// /// ```gleam /// todo |> birdie.snap(title: "wibble") /// // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This! /// ``` /// call_span: Span, /// This is the name of the function where the snapshot test is defined. /// For example: /// /// ```gleam /// fn wibble_test() { /// // ^^^^^^^^^^^ This! /// birdie.snap(...) /// } /// ``` /// test_function_name: String, /// This is span covering the test function name. /// For example: /// /// ```gleam /// fn wibble_test() { /// // ^^^^^^^^^^^ This! /// birdie.snap(...) /// } /// ``` /// test_function_name_span: Span, ) } pub type SnapshotTitle { LiteralTitle(title: String) ExpressionTitle } pub fn new() -> Analyser { Analyser(modules: dict.new(), literal_titles: dict.new()) } pub fn remove_module(analyser: Analyser, module: Uri) -> Analyser { let Analyser(modules:, literal_titles:) = analyser case dict.get(modules, module) { // We were asked to remove a module which wasn't analysed in the first // place, or that has already been removed. We do nothing! Error(_) -> analyser // Found the module we should remove. Ok(module) -> { // We need to remove it from the analysed modules... let modules = dict.delete(modules, module.path) // ...and we also need to remove its names from all the name references! // In order to do that we go over all the names the module defined and // update them removing the reference. let literal_titles = list.fold(module.snapshots, literal_titles, fn(names, snapshot) { remove_snapshot_title(snapshot, in: module, from: names) }) Analyser(modules:, literal_titles:) } } } fn remove_snapshot_title( snapshot: SnapshotTest, in module: AnalysedModule, from names: Dict(String, Dict(Uri, List(SnapshotTest))), ) -> Dict(String, Dict(Uri, List(SnapshotTest))) { case snapshot.title { // If the snapshot doesn't have a literal title then it can't be part of the // names, we just skip it! ExpressionTitle(..) -> names // Otherwise we need to remove it from the names. LiteralTitle(title:) -> case dict.get(names, title) { Error(_) -> names Ok(module_to_spans) -> { let module_to_spans = dict.delete(module_to_spans, module.path) dict.insert(names, title, module_to_spans) } } } } pub fn errors(analyser: Analyser) -> List(Error) { dict.fold(analyser.literal_titles, [], fn(acc, _title, snapshots) { let errors = dict.fold(snapshots, [], fn(acc, module, snapshots) { list.fold(snapshots, acc, fn(acc, snapshot) { case dict.get(analyser.modules, module) { Error(_) -> acc Ok(module) -> { [ TitleAlreadyInUse( module: module, title_span: snapshot.title_span, test_function_name_span: snapshot.test_function_name_span, ), ..acc ] } } }) }) case errors { [] | [_] -> acc [_, _, ..] -> errors |> list.append(acc) } }) } pub fn warnings(analyser: Analyser) -> List(Warning) { dict.fold(analyser.modules, [], fn(acc, _, module) { list.fold(module.snapshots, acc, fn(acc, snapshot) { case snapshot.title { LiteralTitle(_) -> acc ExpressionTitle -> [ NonLiteralTitle(module:, title_span: snapshot.title_span), ..acc ] } }) }) } // ---- QUERYING THE ANALYSER -------------------------------------------------- /// Given a string title, this returns all the snapshots that have that title /// as their literal title, paired with the Uri for the module inside of which /// they are defined. /// If a snapshot has an expression title, it will not be included here. /// If there's snapshots with duplicate titles, this will return a list with /// multiple elements. pub fn get_snapshot_tests( analyser: Analyser, titled title: String, ) -> List(#(Uri, SnapshotTest)) { case dict.get(analyser.literal_titles, title) { Error(_) -> [] Ok(modules) -> dict.fold(over: modules, from: [], with: fn(tests, module, new_tests) { list.map(new_tests, fn(new_test) { #(module, new_test) }) |> list.append(tests) }) } } // ---- MODULE ANALYSIS -------------------------------------------------------- pub fn analyse(analyser: Analyser, module module: Module) -> Analyser { case analyse_module(module) { Error(_) -> analyser Ok(module) -> add_analysed_module(analyser, module) } } fn add_analysed_module(analyser: Analyser, module: AnalysedModule) -> Analyser { let Analyser(modules:, literal_titles:) = analyser // We add the module to the analysed ones... let modules = dict.insert(modules, module.path, module) // ...and we keep track of all the literal snapshot names it defines let literal_titles = list.group(module.snapshots, fn(snapshot) { snapshot.title }) |> dict.fold(literal_titles, fn(literal_titles, title, snapshots) { // We only care about snapshots that share a literal title! case title { ExpressionTitle -> literal_titles LiteralTitle(title:) -> { dict.upsert(literal_titles, title, fn(references) { case references { None -> dict.from_list([#(module.path, snapshots)]) Some(references) -> dict.insert(references, module.path, snapshots) } }) } } }) Analyser(modules:, literal_titles:) } /// Analyses a module, returning `Error(Nil)` if the module is not using /// `birdie.snap` at all, or if it can't be parsed for any reason. fn analyse_module(module: Module) -> Result(AnalysedModule, Nil) { // We first need to parse the module, if it contains any error there's not // much we can do! use parsed_module <- result.try( glance.module(module.source) |> result.replace_error(Nil), ) // We then figure out how the `birdie.snap` function might be called inside // the module. If `birdie` isn't imported at all we're done! There's nothing // to do for the module. use snap_usage <- result.try(snap_usage(for: parsed_module)) // We now go over all expressions in the module, collecting all snapshot tests // we can find let snapshots = { use snapshots, function <- list.fold(parsed_module.functions, []) let body = function.definition.body let function_name = function.definition.name // This works on the assumption that the function is typed with a normal // ammount of whitespace. It wouldn't work if there was more empty space; // however, it's a pretty safe assumption. let function_name_start = function.definition.location.start + 7 let function_name_span = glance.Span( start: function_name_start, end: function_name_start + string.byte_size(function_name), ) // pub fn wibble() {} // Each function has a new empty scope. let scope = Scope(set.new()) use snapshots, scope, expression <- fold_statements(body, scope, snapshots) let snapshot = snapshot_test( snap_usage, scope, function_name, function_name_span, expression, ) case snapshot { Ok(snapshot) -> [snapshot, ..snapshots] Error(_) -> snapshots } } case snapshots { [] -> Error(Nil) [_, ..] -> Ok(AnalysedModule(path: module.path, source: module.source, snapshots:)) } } fn snapshot_test( snap_usage: SnapUsage, scope: Scope, // The name of the function inside of which we've found this expression. function_name: String, // The span covering the function name passed above. function_name_span: Span, expression: glance.Expression, ) -> Result(SnapshotTest, Nil) { case expression { // `func(title, content: content)` glance.Call( location: call_span, function:, arguments: [ glance.UnlabelledField(title), glance.LabelledField("content", _location, _snapshot_content), ], ) | // `func(content, title)` glance.Call( location: call_span, function:, arguments: [ glance.UnlabelledField(_snapshot_content), glance.UnlabelledField(title), ], ) | // `func(content, title: title)` glance.Call( location: call_span, function:, arguments: [ glance.UnlabelledField(_snapshot_content), glance.LabelledField("title", _location, title), ], ) | // `func(content: content, title)` glance.Call( location: call_span, function:, arguments: [ glance.LabelledField("content", _location, _snapshot_content), glance.UnlabelledField(title), ], ) | // `func(content: content, title: title)` glance.Call( location: call_span, function:, arguments: [ glance.LabelledField("content", _location, _snapshot_content), glance.LabelledField("title", _location, title), ], ) | // `func(title: title, content)`, `func(title: title, content: content)` glance.Call( location: call_span, function:, arguments: [ glance.LabelledField("title", _location, title), _content_field, ], ) | // `title |> func(content: content)` glance.BinaryOperator( location: call_span, name: glance.Pipe, left: title, right: glance.Call( location: _, function:, arguments: [ glance.LabelledField("content", _location, _snapshot_content), ], ), ) | // `content |> func(title)` glance.BinaryOperator( location: call_span, name: glance.Pipe, left: _snapshot_content, right: glance.Call( location: _, function:, arguments: [glance.UnlabelledField(title)], ), ) | // `content |> func(title: title)` glance.BinaryOperator( location: call_span, name: glance.Pipe, left: _snapshot_content, right: glance.Call( location: _, function:, arguments: [glance.LabelledField("title", _location, title)], ), ) | // `title |> func(content, title: _)` glance.BinaryOperator( location: call_span, name: glance.Pipe, left: title, right: glance.FnCapture( location: _, function:, arguments_before: [_content], label: Some("title"), arguments_after: [], ), ) | // `title |> func(title: _, content)` glance.BinaryOperator( location: call_span, name: glance.Pipe, left: title, right: glance.FnCapture( location: _, function:, arguments_before: [], label: Some("title"), arguments_after: [_content], ), ) | // `title |> func(content, _)` glance.BinaryOperator( location: call_span, name: glance.Pipe, left: title, right: glance.FnCapture( location: _, function:, label: _, arguments_before: [glance.UnlabelledField(_snapshot_content)], arguments_after: [], ), ) -> // Most of the work is done, we have captured all calls that _look like_ // they might be a call to `birdie.snap`, but now we need to make sure // they actually are! case is_snap_function(function, scope, snap_usage) { False -> Error(Nil) True -> Ok(SnapshotTest( title: expression_to_title(title), title_span: title.location, call_span:, test_function_name: function_name, test_function_name_span: function_name_span, )) } // Echo trivially wraps an expression, so we need to check that! glance.Echo(expression: Some(expression), ..) -> snapshot_test( snap_usage, scope, function_name, function_name_span, expression, ) // Everything else cannot be a call to `birdie.snap` (or it is a format // I've forgot about). _ -> Error(Nil) } } fn expression_to_title(title: glance.Expression) -> SnapshotTitle { case title { glance.String(value:, ..) -> LiteralTitle(title: value) glance.Echo(expression: Some(expression), ..) -> expression_to_title(expression) // If we're joining two or more literal strings those are still considered // literal titles, because we can tell at compile time what they will be. glance.BinaryOperator(name: glance.Concatenate, left:, right:, ..) -> case expression_to_title(left) { ExpressionTitle -> ExpressionTitle LiteralTitle(title: left) -> case expression_to_title(right) { LiteralTitle(title: right) -> LiteralTitle(title: left <> right) ExpressionTitle -> ExpressionTitle } } _ -> ExpressionTitle } } /// Returns `True` if the given function is a valid `birdie.snap` call given how /// the function can be used. fn is_snap_function( function: glance.Expression, scope: Scope, snap_usage: SnapUsage, ) -> Bool { case function { // We have an unqualified call: `name(content, title)`. // We must check that the name used is the name that was picked for the // unqualified birdie import. glance.Variable(name: used_snap_name, ..) -> case snap_usage { OnlyQualified(..) -> False QualifiedAndUnqualified(snap_name:, ..) | OnlyUnqualified(snap_name:) -> snap_name == used_snap_name && !set.contains(scope.variables, snap_name) } // We have a qualified call: `module_name.snap(content, title)`. // We must check that the name used is the name that was picked for the // birdie module when imported. glance.FieldAccess( container: glance.Variable(name: used_module_name, ..), label: "snap", .., ) -> case snap_usage { OnlyUnqualified(..) -> False OnlyQualified(birdie_name:) | QualifiedAndUnqualified(birdie_name:, ..) -> used_module_name == birdie_name && !set.contains(scope.variables, birdie_name) } _ -> False } } /// How the `birdie.snap` function can be called in a module. type SnapUsage { /// The birdie module has been imported but the snap function has not been /// imported as unqualified. For example: /// /// ```gleam /// import birdie as wibble /// // ^^^^^^ birdie_name /// ``` /// /// This means the function can only be called qualified as /// `birdie_name.snap`. OnlyQualified(birdie_name: String) /// The snap function has been imported as unqualified with the given name /// and the module itself has been given a name. For example: /// /// ```gleam /// import birdie.{snap as wibble} as wobble /// // ^^^^^^ snap_name /// // ^^^^^^ birdie_name /// ``` /// /// This means the function could be called qualified as /// `module_name.snap_name`, or unqualified as `snap_name`! QualifiedAndUnqualified(birdie_name: String, snap_name: String) /// The birdie module itself is discarded, but the snap function is imported /// with the given name. For example: /// /// ```gleam /// import birdie.{snap as wibble} as _ /// // ^^^^^^ snap_name /// /// /// import birdie.{snap} as _ /// // ^^^^ snap_name /// ``` /// /// This means the function can only be called as `snap_name` unqualified. OnlyUnqualified(snap_name: String) } /// Returns how the `birdie.snap` can be used inside the given module, returning /// `Error(Nil)` if the function can't be used at all! fn snap_usage(for module: glance.Module) -> Result(SnapUsage, Nil) { list.find_map(module.imports, fn(import_) { let glance.Import(module:, alias:, unqualified_values:, ..) = import_.definition // We only care about the import that is importing `birdie`, all the other // ones will be skipped. use <- bool.guard(when: module != "birdie", return: Error(Nil)) // We then figure out what name we need to use for the `snap` function if it // is imported in an unqualified manner. let unqualified_snap_name = list.find_map(unqualified_values, fn(unqualified_import) { case unqualified_import { glance.UnqualifiedImport(name: "snap", alias: None) -> Ok("snap") glance.UnqualifiedImport(name: "snap", alias: Some(name)) -> Ok(name) glance.UnqualifiedImport(name: _, alias: Some(_)) | glance.UnqualifiedImport(name: _, alias: None) -> Error(Nil) } }) case alias, unqualified_snap_name { // `import birdie.{snap}` // `import birdie.{snap as snap_name}` None, Ok(snap_name) -> Ok(QualifiedAndUnqualified(birdie_name: "birdie", snap_name:)) // `import birdie.{snap} as birdie_name` // `import birdie.{snap as snap_name} as birdie_name` Some(glance.Named(birdie_name)), Ok(snap_name) -> Ok(QualifiedAndUnqualified(birdie_name:, snap_name:)) // `import birdie.{snap} as _` // `import birdie.{snap as snap_name} as _` Some(glance.Discarded(_)), Ok(snap_name) -> Ok(OnlyUnqualified(snap_name:)) // `import birdie` None, Error(_) -> Ok(OnlyQualified(birdie_name: "birdie")) // `import birdie as _` Some(glance.Discarded(_)), Error(_) -> Error(Nil) // `import birdie as birdie_name` Some(glance.Named(birdie_name)), Error(_) -> Ok(OnlyQualified(birdie_name:)) } }) } // ---- GLANCE EXPRESSION FOLDING ---------------------------------------------- type Scope { Scope(variables: Set(String)) } fn fold_statements( statements: List(glance.Statement), scope: Scope, acc: a, fun: fn(a, Scope, glance.Expression) -> a, ) -> a { let #(_scope, acc) = list.fold(over: statements, from: #(scope, acc), with: fn(acc, statement) { let #(scope, acc) = acc case statement { // A use expression can introduce new variables into scope. glance.Use(patterns:, function:, ..) -> { // The function on the right hand side of use doesn't see the variable // that it introduces, so we have to go over it before updating the // scope. let acc = fold_expression(function, scope, acc, fun) let scope = list.fold(patterns, scope, fn(scope, use_pattern) { update_scope_from_patterns(scope, [use_pattern.pattern]) }) #(scope, acc) } // An assignment can introduce variables into scope. glance.Assignment(pattern:, value:, ..) -> { // The value on the right hand side of the assignment doesn't see the // variable that it introduces, so we have to go over it before // updating the scope. let acc = fold_expression(value, scope, acc, fun) let scope = update_scope_from_patterns(scope, [pattern]) #(scope, acc) } // Assertions and simple expression cannot introduce new variables in // the current scope! glance.Assert(location: _, expression:, message: None) -> { #(scope, fold_expression(expression, scope, acc, fun)) } glance.Assert(location: _, expression:, message: Some(message)) -> { let acc = fold_expression(expression, scope, acc, fun) #(scope, fold_expression(message, scope, acc, fun)) } glance.Expression(expression) -> { #(scope, fold_expression(expression, scope, acc, fun)) } } }) acc } fn fold_expression( expression: glance.Expression, scope: Scope, acc: a, fun: fn(a, Scope, glance.Expression) -> a, ) -> a { let acc = fun(acc, scope, expression) case expression { glance.Echo(expression: Some(expression), message: None, ..) | glance.Echo(expression: None, message: Some(expression), ..) -> fold_expression(expression, scope, acc, fun) glance.Echo(expression: Some(expression), message: Some(message), ..) -> { let acc = fold_expression(expression, scope, acc, fun) fold_expression(message, scope, acc, fun) } glance.NegateInt(value: expression, ..) | glance.NegateBool(value: expression, ..) | glance.FieldAccess(container: expression, ..) | glance.TupleIndex(tuple: expression, ..) -> fold_expression(expression, scope, acc, fun) glance.Block(statements:, ..) -> fold_statements(statements, scope, acc, fun) glance.Tuple(elements:, ..) | glance.List(elements:, rest: None, ..) -> fold_expressions(elements, scope, acc, fun) glance.List(elements:, rest: Some(rest), ..) -> { let acc = fold_expressions(elements, scope, acc, fun) fold_expression(rest, scope, acc, fun) } glance.Fn(body: statements, arguments:, ..) -> { let scope = list.fold(arguments, scope, fn(scope, argument) { case argument.name { glance.Discarded(_) -> scope glance.Named(name) -> Scope(set.insert(scope.variables, name)) } }) fold_statements(statements, scope, acc, fun) } glance.RecordUpdate(record:, fields:, ..) -> { let acc = fold_expression(record, scope, acc, fun) list.fold(over: fields, from: acc, with: fn(acc, field) { case field.item { Some(item) -> fold_expression(item, scope, acc, fun) None -> acc } }) } glance.Call(function:, arguments:, ..) -> { let acc = fold_expression(function, scope, acc, fun) fold_fields(arguments, scope, acc, fun) } glance.FnCapture(function:, arguments_before:, arguments_after:, ..) -> { let acc = fold_expression(function, scope, acc, fun) let acc = fold_fields(arguments_before, scope, acc, fun) fold_fields(arguments_after, scope, acc, fun) } glance.Case(subjects:, clauses:, ..) -> { let acc = fold_expressions(subjects, scope, acc, fun) fold_clauses(clauses, scope, acc, fun) } glance.BinaryOperator(left:, right:, ..) -> { let acc = fold_expression(left, scope, acc, fun) fold_expression(right, scope, acc, fun) } glance.Panic(message: Some(expression), ..) | glance.Todo(message: Some(expression), ..) -> fold_expression(expression, scope, acc, fun) // We can't find any `birdie.snap` call here for sure. glance.Panic(message: None, ..) | glance.Todo(message: None, ..) | glance.BitString(..) | glance.Int(..) | glance.Float(..) | glance.String(..) | glance.Variable(..) | glance.Echo(expression: None, message: None, ..) -> acc } } fn fold_fields( fields: List(glance.Field(glance.Expression)), scope: Scope, acc: a, fun: fn(a, Scope, glance.Expression) -> a, ) -> a { list.fold(over: fields, from: acc, with: fn(acc, field) { case field { glance.LabelledField(item:, ..) | glance.UnlabelledField(item:) -> fold_expression(item, scope, acc, fun) glance.ShorthandField(..) -> acc } }) } fn fold_clauses( clauses: List(glance.Clause), scope: Scope, acc: a, fun: fn(a, Scope, glance.Expression) -> a, ) -> a { list.fold(over: clauses, from: acc, with: fn(acc, clause) { case clause { glance.Clause(patterns:, guard: None, body:) -> { let scope = list.fold(patterns, scope, update_scope_from_patterns) fold_expression(body, scope, acc, fun) } glance.Clause(patterns:, guard: Some(guard), body:) -> { let scope = list.fold(patterns, scope, update_scope_from_patterns) let acc = fold_expression(guard, scope, acc, fun) fold_expression(body, scope, acc, fun) } } }) } fn fold_expressions( expressions: List(glance.Expression), scope: Scope, acc: a, fun: fn(a, Scope, glance.Expression) -> a, ) -> a { list.fold(expressions, acc, fn(acc, expression) { fold_expression(expression, scope, acc, fun) }) } fn update_scope_from_patterns( scope: Scope, patterns: List(glance.Pattern), ) -> Scope { Scope(list.fold(patterns, scope.variables, pattern_variables)) } fn pattern_variables(acc: Set(String), pattern: glance.Pattern) -> Set(String) { case pattern { glance.PatternInt(..) -> acc glance.PatternFloat(..) -> acc glance.PatternString(..) -> acc glance.PatternDiscard(..) -> acc glance.PatternVariable(name:, ..) -> set.insert(acc, name) glance.PatternTuple(elements:, ..) -> list.fold(elements, acc, pattern_variables) glance.PatternList(elements:, tail: None, ..) -> list.fold(elements, acc, pattern_variables) glance.PatternList(elements:, tail: Some(tail), ..) -> { let acc = list.fold(elements, acc, pattern_variables) pattern_variables(acc, tail) } glance.PatternAssignment(pattern:, name:, ..) -> { let acc = set.insert(acc, name) pattern_variables(acc, pattern) } glance.PatternConcatenate(prefix_name:, rest_name:, ..) -> { let acc = case prefix_name { Some(glance.Discarded(_)) | None -> acc Some(glance.Named(name)) -> set.insert(acc, name) } case rest_name { glance.Named(name) -> set.insert(acc, name) glance.Discarded(_) -> acc } } glance.PatternBitString(segments:, ..) -> list.fold(segments, acc, fn(acc, segment) { pattern_variables(acc, segment.0) }) glance.PatternVariant(arguments:, ..) -> list.fold(arguments, acc, fn(acc, argument) { case argument { glance.ShorthandField(label:, ..) -> set.insert(acc, label) glance.LabelledField(item:, ..) | glance.UnlabelledField(item:) -> pattern_variables(acc, item) } }) } } // ------ ERROR PRETTY PRINTING ------------------------------------------------ pub fn error_to_diagnostic(error: Error) -> Diagnostic { case error { TitleAlreadyInUse(module:, test_function_name_span:, title_span:) -> diagnostic.Diagnostic( level: diagnostic.Erro, title: "duplicate snapshot title", label: Some(diagnostic.Label( file_name: module.path.path, source: module.source, position: title_span, content: "multiple snapshots have this title", secondary_label: Some(#( test_function_name_span, "defined in this function", )), )), text: "Snapshot titles should be unique but title is duplicated.", hint: Some("change this title so that it is unique"), ) } } ================================================ FILE: src/birdie/internal/cli.gleam ================================================ import edit_distance import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import gleam_community/ansi pub type Command { Review Accept Reject Help Stale(subcommand: StaleSubcommand) WithHelpOption(command: Command, explained: Explained) } pub type StaleSubcommand { CheckStale DeleteStale } pub type Explained { /// We're tasked with helping with a command in full, considering its sub /// commands as well: `gleam run -m birdie stale check --help` /// FullCommand /// We're tasked with helping with a command that would otherwise be invalid /// because it's missing some required subcommand, it's still valid to ask for /// help there: `gleam run -m birdie stale --help` TopLevelCommand } pub type Error { UnknownCommand(command: String) UnknownSubcommand(command: Command, subcommand: String) UnexpectedArgument(command: Command, argument: String) UnknownOption(command: Command, option: String) MissingSubcommand(command: Command) } pub fn parse(args: List(String)) -> Result(Command, Error) { let #(commands, options) = list.partition(args, fn(arg) { case arg { "--" <> _ | "-" <> _ -> False _ -> True } }) case commands { [] -> Review |> or_help(options) ["review"] -> Review |> or_help(options) ["review", subcommand, ..] -> Error(UnknownSubcommand(command: Review, subcommand:)) ["reject"] -> Reject |> or_help(options) ["reject", subcommand, ..] -> Error(UnknownSubcommand(command: Reject, subcommand:)) ["accept"] -> Accept |> or_help(options) ["accept", subcommand, ..] -> Error(UnknownSubcommand(command: Accept, subcommand:)) ["stale"] -> Stale(CheckStale) |> require_help(options) ["stale", "check"] -> Stale(CheckStale) |> or_help(options) ["stale", "check", argument, ..] -> Error(UnexpectedArgument(command: Stale(CheckStale), argument:)) ["stale", "delete"] -> Stale(DeleteStale) |> or_help(options) ["stale", "delete", argument, ..] -> Error(UnexpectedArgument(command: Stale(DeleteStale), argument:)) ["stale", subcommand, ..] -> Error(UnknownSubcommand(command: Stale(CheckStale), subcommand:)) ["help", ..] -> Ok(Help) [command, ..] -> Error(UnknownCommand(command:)) } } fn or_help(command: Command, options: List(String)) -> Result(Command, Error) { case list.find(options, one_that: fn(option) { !is_help(option) }) { Ok(option) -> Error(UnknownOption(command:, option:)) Error(_) -> case list.any(options, is_help) { True -> Ok(WithHelpOption(command, explained: FullCommand)) False -> Ok(command) } } } fn require_help( command: Command, options: List(String), ) -> Result(Command, Error) { case list.find(options, one_that: fn(option) { !is_help(option) }) { Ok(option) -> Error(UnknownOption(command:, option:)) Error(_) -> case list.any(options, is_help) { True -> Ok(WithHelpOption(command, explained: TopLevelCommand)) False -> Error(MissingSubcommand(command:)) } } } fn is_help(option: String) -> Bool { case option { "-h" | "--help" -> True _ -> False } } /// This will return one of the allowed commands if there's one that's similar /// enough to the given one. /// pub fn similar_command(to command: String) -> Result(String, Nil) { list.filter_map(all_commands(), fn(valid_command) { case edit_distance.levenshtein(command, valid_command) { distance if distance <= 3 -> Ok(#(valid_command, distance)) _ -> Error(Nil) } }) |> list.sort(fn(one, other) { int.compare(one.1, other.1) }) |> list.first |> result.map(fn(pair) { pair.0 }) } pub fn all_commands() -> List(String) { ["accept", "help", "reject", "review", "stale"] } // ERROR MESSAGES -------------------------------------------------------------- pub fn unknown_command_error(command: String, show_help_text: Bool) -> String { let message = ansi.red("Error: ") <> style_invalid_value(command) <> " is not a valid command" case show_help_text { False -> message True -> message <> "\n\n" <> main_help_text() } } pub fn unknown_subcommand_error( birdie_version: String, command: Command, subcommand: String, ) -> String { ansi.red("Error: ") <> style_invalid_value(subcommand) <> " is not a valid subcommand\n\n" <> help_text(birdie_version, for: command, explaining: TopLevelCommand) } pub fn unknown_option_error( birdie_version: String, command: Command, option: String, ) -> String { ansi.red("Error: ") <> style_invalid_value(option) <> " is not a valid option\n\n" <> help_text(birdie_version, for: command, explaining: FullCommand) } pub fn missing_subcommand_error( birdie_version: String, command: Command, ) -> String { ansi.red("Error: ") <> style_invalid_value(command_to_string(command)) <> " is missing a required subcommand\n\n" <> help_text(birdie_version, for: command, explaining: TopLevelCommand) } pub fn unexpected_argument_error( birdie_version: String, command: Command, argument: String, ) -> String { ansi.red("Error: ") <> " unexpected argument " <> style_invalid_value(argument) <> "\n\n" <> help_text(birdie_version, command, explaining: FullCommand) } fn command_to_string(command: Command) { case command { WithHelpOption(command:, ..) -> command_to_string(command) Stale(..) -> "stale" Review -> "review" Accept -> "accept" Reject -> "reject" Help -> "help" } } fn style_invalid_value(value: String) -> String { ansi.yellow("'" <> value <> "'") } // HELP TEXTS ------------------------------------------------------------------ /// Returns the help text for the given command. /// pub fn help_text( birdie_version: String, for command: Command, explaining explained: Explained, ) -> String { case command, explained { // These commands do not have any subcommands, so the explanation text is // always going to be the same, no matter what we're asked to explain. Help(..), _ -> help_help_text(birdie_version) Accept, _ -> accept_help_text() Reject, _ -> reject_help_text() Review, _ -> review_help_text() Stale(subcommand), FullCommand -> stale_help_text(Some(subcommand)) Stale(_), TopLevelCommand -> stale_help_text(None) // This would only happen if we wrapped a `WithHelpOption` command in one // other. Just for the sake of safety I return a value instead of panicking. WithHelpOption(command:, explained:), _ -> help_text(birdie_version, command, explained) } } fn stale_help_text(subcommand: Option(StaleSubcommand)) -> String { case subcommand { None -> { let stale_subcommands = ansi.yellow("Subcommands:\n") <> ansi.green(" check ") <> "check if there's any stale snapshot\n" <> ansi.green(" delete ") <> "delete all stale snapshots" usage(["stale"], Some(Subcommand)) <> "\n\n" <> "Find and remove stale snapshots." <> "\n\n" <> stale_subcommands <> "\n\n" <> options() } Some(CheckStale) -> usage(["stale", "check"], None) <> "\n\n" <> "Check if there's any snapshot that is no longer used by any test.\n" <> "This exits with an error status code if any stale snapshot is found." <> "\n\n" <> options() Some(DeleteStale) -> usage(["stale", "delete"], None) <> "\n\n" <> "Removes any snapshot that is no longer used by any test." <> "\n\n" <> options() } } fn review_help_text() -> String { usage(["review"], None) <> "\n\n" <> "Review all new snapshots one by one" <> "\n\n" <> options() } fn reject_help_text() -> String { usage(["reject"], None) <> "\n\n" <> "Reject all new snapshots" <> "\n\n" <> options() } fn accept_help_text() -> String { usage(["accept"], None) <> "\n\n" <> "Accept all new snapshots" <> "\n\n" <> options() } fn help_help_text(birdie_version: String) -> String { { ansi.green("🐦‍⬛ birdie ") <> "v" <> birdie_version } <> "\n\n" <> main_help_text() } pub fn main_help_text() -> String { usage([], Some(Command)) <> "\n\n" <> command_menu() <> "\n\n" <> options() } fn command_menu() -> String { ansi.yellow("Commands:\n") <> ansi.green(" review ") <> "review all new snapshots one by one\n" <> ansi.green(" accept ") <> "accept all new snapshots\n" <> ansi.green(" reject ") <> "reject all new snapshots\n" <> ansi.green(" stale ") <> "find and remove stale snapshots\n" <> ansi.green(" help ") <> "print this help text" } type ArgumentsKind { Command Subcommand } fn usage( commands: List(String), arguments_kind: Option(ArgumentsKind), ) -> String { let command_placeholder = case arguments_kind { None -> " " Some(Command) -> ansi.dim(" ") Some(Subcommand) -> ansi.dim(" ") } let commands = case commands { [] -> "" [_, ..] -> " " <> ansi.green(string.join(commands, with: " ")) } ansi.yellow("Usage: ") <> "gleam run -m" <> ansi.green(" birdie") <> commands <> command_placeholder <> ansi.dim("[OPTIONS]") } fn options() -> String { let help_option = ansi.green("-h") <> ", " <> ansi.green("--help") ansi.yellow("Options:") <> "\n " <> help_option <> " print this help text" } ================================================ FILE: src/birdie/internal/diagnostic.gleam ================================================ import glance.{type Span} import gleam/bit_array import gleam/int import gleam/option.{type Option} import gleam/result import gleam/string import gleam_community/ansi pub type Diagnostic { Diagnostic( level: Level, title: String, /// If the diagnostic points to some specific position in a source file this /// will be `Some`, with the information needed to display such tooltip. label: Option(Label), text: String, /// Some text displayed after the main text, always preceded by the `Hint:` /// label. hint: Option(String), ) } pub type Level { Warn Erro } pub type Label { Label( /// The name of the file where the source comes from. file_name: String, /// The source code this label points to. source: String, /// The position in the source code we need to point to. position: Span, /// The content of the label. If this is not an empty string, the label /// will have a tooltip connecting it to the label text. content: String, /// This is an additional label that can be used to add additional context /// and is rendered with a muted color. secondary_label: Option(#(Span, String)), ) } pub fn to_string(diagnostic: Diagnostic) { let Diagnostic(level:, title:, label:, text:, hint:) = diagnostic let text = string.trim(text) let heading = case level { Warn -> ansi.yellow("warning") Erro -> ansi.red("error") } // We start with the heading line... let error = ansi.bold(heading <> ": " <> title) // ...followed by the label pointing to some source, if present. let error = case label { option.None -> error option.Some(label) -> error <> "\n" <> label_to_string(level, label) } // Then we add the error text... let error = case string.trim(text) { "" -> error text -> error <> "\n" <> text } // ...and finally, if present, we also add the hint! let error = case option.map(hint, string.trim) { option.Some("") | option.None -> error option.Some(hint) -> error <> "\nHint: " <> hint } error } /// Turns a label into a pretty string, pointing to the original source. fn label_to_string(level: Level, label: Label) -> String { let Label(file_name:, source:, position:, content:, secondary_label:) = label let colour = case level { Warn -> ansi.yellow Erro -> ansi.red } let #(start_line, start_line_number, trimmed_to_start) = get_line(source, containing: position.start) let #(end_line, end_line_number, trimmed_to_end) = get_line(source, containing: position.end) let is_single_line = start_line_number == end_line_number let required_digits = int.to_string(start_line_number) |> string.length |> int.max(int.to_string(end_line_number) |> string.length) let file_name = string.repeat(" ", required_digits) <> ansi.dim(" ╭─ ") <> file_name let empty_line = string.repeat(" ", required_digits) <> ansi.dim(" │") let highlighted_code = case is_single_line { True -> { let start_line = colour_string_between_bytes( start_line, position.start - trimmed_to_start, position.end - trimmed_to_start, colour, ) ansi.dim(int.to_string(start_line_number) <> " │ ") <> start_line } False -> { let start_line = colour_string_between_bytes( start_line, position.start - trimmed_to_start, string.byte_size(start_line), colour, ) let end_line = colour_string_between_bytes( end_line, 0, position.end - trimmed_to_end, colour, ) let start_line = ansi.dim( string.pad_start( int.to_string(start_line_number), required_digits, " ", ) <> " │ ", ) <> start_line let end_line = ansi.dim( string.pad_start(int.to_string(end_line_number), required_digits, " ") <> " │ ", ) <> end_line case end_line_number - start_line_number { 0 | 1 -> start_line <> "\n" <> end_line _ -> { let dashed_line = string.repeat(" ", required_digits) <> ansi.dim(" ╎") start_line <> "\n" <> dashed_line <> "\n" <> end_line } } } } // Now we need to add the tooltip to the highlighted code, if the tooltip has // any text! let tooltip = case is_single_line { True -> { empty_line <> " " <> string.repeat(" ", position.start - trimmed_to_start) <> string.repeat(colour("^"), position.end - position.start) <> case string.trim(content) { "" -> "" content -> " " <> colour(content) } } False -> { empty_line <> " " <> string.repeat(colour("^"), string.byte_size(start_line)) <> case string.trim(content) { "" -> "" content -> " " <> colour(content) } } } let primary_label = highlighted_code <> "\n" <> tooltip let labels = case secondary_label { option.None -> primary_label option.Some(#(span, content)) -> { let #(secondary_line, secondary_line_number, dropped_bytes) = get_line(source, containing: span.start) let secondary_line = colour_string_between_bytes( secondary_line, span.start - dropped_bytes, span.end - dropped_bytes, ansi.dim, ) let secondary_tooltip = empty_line <> " " <> string.repeat(" ", span.start - dropped_bytes) <> string.repeat(ansi.dim("~"), span.end - span.start) <> case string.trim(content) { "" -> "" content -> " " <> ansi.dim(content) } let secondary_label = ansi.dim( string.pad_start( int.to_string(secondary_line_number), required_digits, " ", ) <> " │ ", ) <> secondary_line <> "\n" <> secondary_tooltip let dashed_line = string.repeat(" ", required_digits) <> ansi.dim(" ╎") case secondary_line_number - start_line_number { 0 -> primary_label 1 -> primary_label <> "\n" <> secondary_label n if n == -1 -> secondary_label <> "\n" <> primary_label n if n < 0 -> secondary_label <> "\n" <> dashed_line <> "\n" <> primary_label _ -> primary_label <> "\n" <> dashed_line <> "\n" <> secondary_label } } } file_name <> "\n" <> empty_line <> "\n" <> labels <> "\n" <> empty_line } fn colour_string_between_bytes( string: String, start: Int, end: Int, colour: fn(String) -> String, ) -> String { let result = case <> { << prefix:size(start)-bytes, to_colour:size(end - start)-bytes, suffix:bytes, >> -> { use prefix <- result.try(bit_array.to_string(prefix)) use to_colour <- result.try(bit_array.to_string(to_colour)) use suffix <- result.try(bit_array.to_string(suffix)) Ok(prefix <> colour(to_colour) <> suffix) } _ -> Error(Nil) } result.unwrap(result, string) } /// This returns /// - the line in `string` containing the given byte /// - the number of such line in the source code /// - the number of bytes that come before such line /// fn get_line(string: String, containing byte: Int) -> #(String, Int, Int) { get_line_loop(string, 1, 0, byte) } fn get_line_loop( string: String, line_number: Int, trimmed_bytes: Int, byte: Int, ) -> #(String, Int, Int) { case string.split_once(string, on: "\n") { // This is the last line! We can't keep going any further Error(_) -> #(string, line_number, trimmed_bytes) Ok(#(line, rest)) -> case trimmed_bytes + string.byte_size(line) + 1 { // The byte falls inside this line, so we have to return it. trimmed if trimmed > byte -> #(line, line_number, trimmed_bytes) trimmed -> get_line_loop(rest, line_number + 1, trimmed, byte) } } } ================================================ FILE: src/birdie/internal/diff.gleam ================================================ import gleam/dict.{type Dict} import gleam/list import gleam/option.{type Option, None, Some} import gleam/string pub type DiffLine { DiffLine(number: Int, line: String, kind: DiffLineKind) } pub type DiffLineKind { Old New Shared } pub fn histogram(one, other) { let one_lines = string.split(one, on: "\n") let other_lines = string.split(other, on: "\n") let lcs = lcs(one_lines, other_lines) match_diff_lines([], lcs, 1, one_lines, 1, other_lines) } fn match_diff_lines( lines: List(DiffLine), lcs: List(String), line_one: Int, one: List(String), line_other: Int, other: List(String), ) -> List(DiffLine) { case lcs, one, other { // We drained all the lines, we can return the accumulator which was built // in reverse order. [], [], [] -> list.reverse(lines) // If we don't have any more lines in the common prefix we first drain all // the lines from the first list marking those as old. [], [first, ..one], other -> [DiffLine(line_one, first, Old), ..lines] |> match_diff_lines(lcs, line_one + 1, one, line_other, other) // If we've also drained the first list we finish by draining the other one // marking all its lines as new ones. [], [], [first, ..other] -> [DiffLine(line_other, first, New), ..lines] |> match_diff_lines(lcs, line_one, one, line_other + 1, other) // While the first list has lines that are not in common we add those // marking them as old. [first_common, ..], [first_one, ..one], other if first_common != first_one -> [DiffLine(line_one, first_one, Old), ..lines] |> match_diff_lines(lcs, line_one + 1, one, line_other, other) // While the second list has lines that are not in common we add those // marking them as new. [first_common, ..], one, [first_other, ..other] if first_common != first_other -> [DiffLine(line_other, first_other, New), ..lines] |> match_diff_lines(lcs, line_one, one, line_other + 1, other) [first_common, ..lcs], [_, ..one], [_, ..other] -> [DiffLine(line_other, first_common, Shared), ..lines] |> match_diff_lines(lcs, line_one + 1, one, line_other + 1, other) [_, ..], [], _ | [_, ..], _, [] -> panic as "unreachable" } } /// Find the least common subsequences of shared items between two lists. /// /// Reference: https://tiarkrompf.github.io/notes/?/diff-algorithm/ /// fn lcs(one: List(a), other: List(a)) -> List(a) { // The recursive definition is so intuitive and elegant, just please don't // look at how `lowest_occurrence_common_item` is defined. // // 1. We remove the common prefix and suffix from the lists. // 2. In the remaining lists we find the common element that appears in both // the list number of times. // 3. We recursively look for the `lcs` of the pieces that come before the // common element in both lists and the pieces that come after the common // element in both lists. // 4. We join the common prefix and suffix, `lcs`s and common item. let #(prefix, one, other) = pop_common_prefix(between: one, and: other) let #(suffix, one, other) = pop_common_suffix(between: one, and: other) // 💡 A possible optimisation could be using a cache and hit that before // calling this function. That might make things faster as well. case lowest_occurrence_common_item(one, other) { None -> list.flatten([prefix, suffix]) Some(#(item, _, before_a, after_a, before_b, after_b)) -> // 💡 A possible optimisation I want to look into is using bags (super // fast append only) and turn that into a list only after everything is // done. That way we could avoid always repeatedly appending lists. list.flatten([ prefix, lcs(list.reverse(before_a), list.reverse(before_b)), [item], lcs(after_a, after_b), suffix, ]) } } // --- HISTOGRAM --------------------------------------------------------------- type Occurs(a) { One(times: Int, before: List(a), after: List(a)) Other(times: Int, before: List(a), after: List(a)) Both( times: Int, before_one: List(a), after_one: List(a), before_other: List(a), after_other: List(a), ) } fn lowest_occurrence_common_item( between one: List(a), and other: List(a), ) -> Option(#(a, Int, List(a), List(a), List(a), List(a))) { let histogram = histogram_add(to: dict.new(), from: one, with: One, acc: []) |> histogram_add(from: other, with: Other, acc: []) use lowest, a, occurs <- dict.fold(over: histogram, from: None) case occurs { // We're only looking for items that appear in both. One(..) | Other(..) -> lowest Both(n, before_one, after_one, before_other, after_other) -> case lowest { None -> Some(#(a, n, before_one, after_one, before_other, after_other)) // We keep the one that appears the least, so we compare `n` and `m`, // that is the number of occurrences of the current lowest and the new // item. Some(#(_, m, _, _, _, _)) -> case m <= n { True -> lowest False -> #(a, n, before_one, after_one, before_other, after_other) |> Some } } } } fn histogram_add( to histogram: Dict(a, Occurs(a)), from list: List(a), with to_occurrence: fn(Int, List(a), List(a)) -> Occurs(a), acc reverse_prefix: List(a), ) -> Dict(a, Occurs(a)) { case list { [] -> histogram [first, ..rest] -> { use previous <- dict.upsert(in: histogram, update: first) let new_occurrence = to_occurrence(1, reverse_prefix, rest) case previous { Some(occurrence) -> sum_occurrences(occurrence, new_occurrence) None -> new_occurrence } } |> histogram_add(rest, to_occurrence, [first, ..reverse_prefix]) } } // This is not general purpose and only takes into accounts the particular cases // that might occur in the histogram building. In particular we first add all // the `One`s and then all the `Other`s. fn sum_occurrences(one: Occurs(a), other: Occurs(a)) -> Occurs(a) { case one, other { One(n, _, _), One(m, before, after) -> One(n + m, before, after) Other(n, _, _), Other(m, before, after) -> Other(n + m, before, after) One(n, before_one, after_one), Other(m, before_other, after_other) | Both(n, before_one, after_one, _, _), Other(m, before_other, after_other) -> Both(n + m, before_one, after_one, before_other, after_other) _, _ -> panic as "unreachable: sum_occurrences" } } // --- LIST UTILS -------------------------------------------------------------- /// Returns the common prefix between two lists, and the remaining lists after /// removing the common prefix from each one. /// fn pop_common_prefix( between one: List(a), and other: List(a), ) -> #(List(a), List(a), List(a)) { let #(reverse_prefix, one, other) = do_pop_common_prefix([], one, other) #(list.reverse(reverse_prefix), one, other) } fn do_pop_common_prefix( reverse_prefix: List(a), one: List(a), other: List(a), ) -> #(List(a), List(a), List(a)) { case one, other { [first_one, ..one], [first_other, ..other] if first_one == first_other -> do_pop_common_prefix([first_one, ..reverse_prefix], one, other) _, _ -> #(reverse_prefix, one, other) } } /// Returns the common suffix between two lists, and the remaining lists after /// removing the common suffix from each one. /// fn pop_common_suffix( between one: List(a), and other: List(a), ) -> #(List(a), List(a), List(a)) { let #(suffix, reverse_one, reverse_other) = do_pop_common_prefix([], list.reverse(one), list.reverse(other)) #(suffix, list.reverse(reverse_one), list.reverse(reverse_other)) } ================================================ FILE: src/birdie/internal/project.gleam ================================================ import filepath import gleam/result import simplifile.{type FileError} import tom /// Returns the path to the project's root. /// /// > ⚠️ This assumes that this is only ever run inside a Gleam's project and /// > sooner or later it will reach a `gleam.toml` file. /// > Otherwise this will end up in an infinite loop, I think. /// pub fn find_root() -> Result(String, FileError) { do_find_root(".") } fn do_find_root(path: String) -> Result(String, FileError) { let manifest = filepath.join(path, "gleam.toml") case simplifile.is_file(manifest) { Ok(True) -> Ok(path) Ok(False) -> do_find_root(filepath.join(path, "..")) Error(reason) -> Error(reason) } } /// Returns the project's name as specified in its `gleam.toml`. /// pub fn name() -> Result(String, FileError) { use root <- result.try(find_root()) use file <- result.try(simplifile.read(filepath.join(root, "gleam.toml"))) let assert Ok(toml) = tom.parse(file) as "running birdie in a gleam project with an invalid `gleam.toml` should be impossible" let assert Ok(name) = tom.get_string(toml, ["name"]) as "`name` is a required field in `gleam.toml`, it should be impossible to run birdie on a project that doesn't have one" Ok(name) } ================================================ FILE: src/birdie/internal/version.gleam ================================================ import gleam/int import gleam/order import gleam/result import gleam/string pub type Version { Version(major: Int, minor: Int, patch: Int) } pub fn new(major major: Int, minor minor: Int, patch patch: Int) -> Version { Version(major:, minor:, patch:) } pub fn parse(version: String) -> Result(Version, Nil) { case version |> string.trim |> string.split(on: ".") { [major, minor, patch] -> { use major <- result.try(int.parse(major)) use minor <- result.try(int.parse(minor)) use patch <- result.try(int.parse(patch)) Ok(Version(major:, minor:, patch:)) } _ -> Error(Nil) } } pub fn compare(one: Version, with other: Version) -> order.Order { use <- order.lazy_break_tie(int.compare(one.major, other.major)) use <- order.lazy_break_tie(int.compare(one.minor, other.minor)) int.compare(one.patch, other.patch) } ================================================ FILE: src/birdie.gleam ================================================ import argv import birdie/internal/analyser.{type Analyser} import birdie/internal/cli.{ type Command, Accept, CheckStale, DeleteStale, FullCommand, Help, MissingSubcommand, Reject, Review, Stale, UnexpectedArgument, UnknownCommand, UnknownOption, UnknownSubcommand, WithHelpOption, } import birdie/internal/diagnostic import birdie/internal/diff.{type DiffLine, DiffLine} import birdie/internal/project import birdie/internal/version import envoy import filepath import gleam/bool import gleam/int import gleam/io import gleam/list import gleam/option.{type Option, None, Some} import gleam/order import gleam/result import gleam/set import gleam/string import gleam/uri import gleam_community/ansi import global_value import justin import rank import simplifile.{Eexist, Enoent} import term_size const birdie_version = "2.0.0" const hint_review_message = "run `gleam run -m birdie` to review the snapshots" const accepted_extension = "accepted" const new_extension = "new" type Error { SnapshotWithEmptyTitle CannotCreateSnapshotsFolder(reason: simplifile.FileError) CannotReadAcceptedSnapshot(reason: simplifile.FileError, source: String) CannotReadNewSnapshot(reason: simplifile.FileError, source: String) CannotSaveNewSnapshot( reason: simplifile.FileError, title: String, destination: String, ) CannotReadSnapshots(reason: simplifile.FileError, folder: String) CannotRejectSnapshot(reason: simplifile.FileError, snapshot: String) CannotAcceptSnapshot(reason: simplifile.FileError, snapshot: String) CannotReadUserInput CorruptedSnapshot(source: String) CannotFindProjectRoot(reason: simplifile.FileError) CannotCreateReferencedFile(file: String, reason: simplifile.FileError) CannotReadReferencedFile(file: String, reason: simplifile.FileError) CannotMarkSnapshotAsReferenced(reason: simplifile.FileError) StaleSnapshotsFound(stale_snapshots: List(String)) CannotDeleteStaleSnapshot(reason: simplifile.FileError) MissingReferencedFile /// This happens when we try and list all the files inside the `test/` /// directory and for some reason the operation fails. CannotReadTestDirectory(reason: simplifile.FileError) /// This happens when we're trying to read the content of a test file to then /// analyse it, but the operation fails for some reason. CannotReadTestFile(reason: simplifile.FileError, file: String) CannotFigureOutProjectName(reason: simplifile.FileError) /// This happens if there's any analysis error with the project modules. AnalysisError(errors: List(analyser.Error)) /// This happens when it's not possible to move the legacy snapshot folder to /// the new expected location under tests. CannotMigrateBirdieSnapshotDirectory( reason: simplifile.FileError, from: String, to: String, ) } // --- THE SNAPSHOT TYPE ------------------------------------------------------- type New type Accepted type Snapshot(status) { Snapshot(title: String, content: String, info: Option(SnapshotInfo)) } type SnapshotInfo { SnapshotInfo( /// The path to the file where the snapshot is defined. file: String, /// The name of the function inside of which the snapshot test is defined. /// For example: /// /// ```gleam /// pub fn wibble_test() { /// // ^^^^^^^^^^^ This! /// birdie.snap(...) /// } /// ``` test_function_name: String, ) } // --- SNAP -------------------------------------------------------------------- /// Returns the path to the referenced file, initialising it to be empty only /// the first time this function is called. /// fn global_referenced_file() -> Result(String, Error) { use <- global_value.create_with_unique_name("birdie.referenced_file") use referenced_file <- result.try(referenced_file_path()) case simplifile.create_file(referenced_file) { Ok(_) -> Ok(referenced_file) Error(Eexist) -> simplifile.write("", to: referenced_file) |> result.replace(referenced_file) |> result.map_error(CannotCreateReferencedFile( file: referenced_file, reason: _, )) Error(reason) -> Error(CannotCreateReferencedFile(file: referenced_file, reason:)) } } fn referenced_file_path() -> Result(String, Error) { use name <- result.try( project.name() |> result.map_error(CannotFigureOutProjectName), ) Ok(filepath.join(get_temp_directory(), name <> "_referenced.txt")) } fn get_temp_directory() -> String { let temp = { use <- result.lazy_or(envoy.get("TMPDIR")) use <- result.lazy_or(envoy.get("TEMP")) envoy.get("TMP") } case temp { Ok(temp) -> temp Error(_) -> case is_windows() { True -> "C:\\TMP" False -> "/tmp" } } } @external(erlang, "birdie_ffi", "is_windows") @external(javascript, "./birdie_ffi.mjs", "is_windows") fn is_windows() -> Bool /// Finds the snapshots folder at the root of the project the command is run /// into. If it's not present the folder is created automatically. /// fn snapshot_folder() -> Result(String, Error) { use <- global_value.create_with_unique_name("birdie.snapshot_folder") use snapshot_folder <- result.try(snapshot_folder_name()) use legacy_snapshot_folder <- result.try(legacy_snapshot_folder_name()) case simplifile.is_directory(snapshot_folder) { Ok(True) -> Ok(snapshot_folder) Ok(False) | Error(Enoent) -> case simplifile.is_directory(legacy_snapshot_folder) { Ok(True) -> Ok(legacy_snapshot_folder) Ok(False) | Error(Enoent) -> case simplifile.create_directory(snapshot_folder) { Ok(_) -> Ok(snapshot_folder) Error(error) -> Error(CannotCreateSnapshotsFolder(error)) } Error(error) -> Error(CannotCreateSnapshotsFolder(error)) } Error(error) -> Error(CannotCreateSnapshotsFolder(error)) } } fn snapshot_folder_name() -> Result(String, Error) { use <- global_value.create_with_unique_name("birdie.snapshot_folder_name") let result = result.map_error(project.find_root(), CannotFindProjectRoot) use project_root <- result.try(result) project_root |> filepath.join("test") |> filepath.join("birdie_snapshots") |> Ok } /// This returns the name of the snapshot folder that was used before `1.6.0`. fn legacy_snapshot_folder_name() -> Result(String, Error) { use <- global_value.create_with_unique_name("birdie.legacy_snapshot_folder") let result = result.map_error(project.find_root(), CannotFindProjectRoot) use project_root <- result.try(result) Ok(filepath.join(project_root, "birdie_snapshots")) } /// Performs a snapshot test with the given title, saving the content to a new /// snapshot file. All your snapshots will be stored in a folder called /// `birdie_snapshots` in the project's root. /// /// The test will fail if there already is an accepted snapshot with the same /// title and a different content. /// The test will also fail if there's no accepted snapshot with the same title /// to make sure you will review new snapshots as well. /// /// > 🚨 A snapshot is saved to a file named after its title, so all titles /// > should be unique! Otherwise you'd end up comparing unrelated snapshots. /// /// > 🐦‍⬛ To review all your snapshots interactively you can run /// > `gleam run -m birdie`. /// > /// > To get an help text and all the available options you can run /// > `gleam run -m birdie help`. /// pub fn snap(content content: String, title title: String) -> Nil { case do_snap(content, title) { Ok(Same) -> Nil Ok(NewSnapshotCreated(snapshot, destination: _)) -> { let hint_message = ansi.yellow(hint_review_message) let hint = InfoLineWithTitle(hint_message, DoNotSplit, "hint") let box = new_snapshot_box(snapshot, [hint]) io.println_error("\n\n" <> box <> "\n") panic as "Birdie snapshot test failed" } Ok(Different(accepted, new)) -> { let hint_message = ansi.yellow(hint_review_message) let hint = InfoLineWithTitle(hint_message, DoNotSplit, "hint") let box = diff_snapshot_box(accepted, new, [hint]) io.println_error("\n\n" <> box <> "\n") panic as "Birdie snapshot test failed" } Error(error) -> panic as { "Birdie snapshot test failed\n" <> to_diagnostic(error) |> list.map(diagnostic.to_string) |> string.join(with: "\n\n") } } } type Outcome { NewSnapshotCreated(snapshot: Snapshot(New), destination: String) Different(accepted: Snapshot(Accepted), new: Snapshot(New)) Same } fn do_snap(content: String, title: String) -> Result(Outcome, Error) { use _ <- result.try(validate_snapshot_title(title)) // We have to find the snapshot folder since the `gleam test` command might // be run from any subfolder we can't just assume we're in the project's root. use folder <- result.try(snapshot_folder()) // 🚨 When snapping with the `snap` function we don't try and get the test // info from the file it's defined in. That would require re-parsing the test // directory every single time the `snap` function is called. We just put the // `info` field to `None`. // // That additional data will be retrieved and updated during the review // process where the parsing of the test directory can be done just once for // all the tests. // // 💡 TODO: I could investigate using a shared cache or something but it // sounds like a pain to implement and should have to work for both // targets. let new = Snapshot(title:, content:, info: None) let new_snapshot_path = new_destination(new, folder) let accepted_snapshot_path = to_accepted_path(new_snapshot_path) // Find an accepted snapshot with the same title to make a comparison. use accepted <- result.try(read_accepted(accepted_snapshot_path)) case accepted { // If there's no accepted snapshot then we save the new one as there's no // comparison to be made. None -> { use _ <- result.try(save(new, to: new_snapshot_path)) Ok(NewSnapshotCreated(snapshot: new, destination: new_snapshot_path)) } // If there's a corresponding accepted snapshot we compare it with the new // one. Some(accepted) -> { // Whenever we find an existing accepted snapshot file, we record that it // has been referenced in the current run. So we know it will not be // stale! use referenced_file <- result.try(global_referenced_file()) use _ <- result.try( simplifile.append( filepath.base_name(accepted_snapshot_path) <> "\n", to: referenced_file, ) |> result.map_error(CannotMarkSnapshotAsReferenced), ) case accepted.content == new.content { True -> { // If the file is ok we make sure to delete any lingering `.new` file // that might have been leftover from somewhere else. let _ = simplifile.delete(new_snapshot_path) Ok(Same) } False -> { // If the new snapshot is the same as the old one then there's no need // to save it in a `.new` file: we can just say they are the same. use _ <- result.try(save(new, to: new_snapshot_path)) Ok(Different(accepted, new)) } } } } } fn validate_snapshot_title(title: String) -> Result(Nil, Error) { case string.trim(title) { "" -> Error(SnapshotWithEmptyTitle) _ -> Ok(Nil) } } // --- SNAPSHOT CONTENT DIFFING ------------------------------------------------ fn to_diff_lines( accepted: Snapshot(Accepted), new: Snapshot(New), ) -> List(DiffLine) { let Snapshot(title: _, content: accepted_content, info: _) = accepted let Snapshot(title: _, content: new_content, info: _) = new diff.histogram(accepted_content, new_content) } // --- SNAPSHOT (DE)SERIALISATION ---------------------------------------------- fn split_n( string, times n: Int, on separator: String, ) -> Result(#(List(String), String), Nil) { case n <= 0 { True -> Ok(#([], string)) False -> { use #(line, rest) <- result.try(string.split_once(string, on: separator)) use #(lines, rest) <- result.try(split_n(rest, n - 1, separator)) Ok(#([line, ..lines], rest)) } } } fn deserialise(raw: String) -> Result(Snapshot(a), Nil) { case split_n(raw, 4, "\n") { Ok(#(["---", "version: " <> version, "title: " <> title, "---"], content)) | Ok(#( ["---\r", "version: " <> version, "title: " <> title, "---\r"], content, )) -> Ok(Snapshot( title: string.trim(title), content: trim_content(content, based_on: version), info: None, )) Ok(_) | Error(_) -> case split_n(raw, 6, "\n") { Ok(#( [ "---", "version: " <> version, "title: " <> title, "file: " <> file, "test_name: " <> test_name, "---", ], content, )) | Ok(#( [ "---\r", "version: " <> version, "title: " <> title, "file: " <> file, "test_name: " <> test_name, "---\r", ], content, )) -> Ok(Snapshot( title: string.trim(title), content: trim_content(content, based_on: version), info: Some(SnapshotInfo( file: string.trim(file), test_function_name: string.trim(test_name), )), )) Ok(_) | Error(_) -> Error(Nil) } } } /// Birdie started adding newlines to the end of files starting from `1.4.0`, /// so if we're reading a snapshot created from `1.4.0` onwards then we want to /// make sure to remove that newline! /// fn trim_content(content: String, based_on version: String) -> String { let assert Ok(version) = version.parse(version) as "corrupt birdie version" case version.compare(version, version.new(1, 4, 0)) { order.Gt | order.Eq -> trim_end_once(content, "\n") order.Lt -> content } } fn trim_end_once(string: String, substring: String) { case string.ends_with(string, substring) { True -> string.drop_end(string, string.length(substring)) False -> string } } fn serialise(snapshot: Snapshot(New)) -> String { let Snapshot(title:, content:, info:) = snapshot let info_lines = case info { None -> [] Some(SnapshotInfo(file:, test_function_name:)) -> [ "file: " <> file, "test_name: " <> test_function_name, ] } [ [ "---", "version: " <> birdie_version, // We escape the newlines in the title so that it fits on one line and it's // easier to parse. // Is this the best course of action? Probably not. // Does this make my life a lot easier? Absolutely! 😁 "title: " <> string.replace(title, each: "\n", with: "\\n"), ], info_lines, ["---", content], ] |> list.flatten |> string.join(with: "\n") // We always add a newline at the end of each snapshot to make sure they're // valid files. |> string.append("\n") } // --- FILE SYSTEM OPERATIONS -------------------------------------------------- /// Save a new snapshot to a given path. /// fn save(snapshot: Snapshot(New), to destination: String) -> Result(Nil, Error) { // Just to make sure I'm not messing up something anywhere else in the code // base: a new snapshot's destination MUST always end with a `.new` extension. // If it doesn't there's a fatal error in my code and I should fix it. case string.ends_with(destination, ".new") { False -> panic as "Looks like I've messed up something, all new snapshots should have the `.new` extension" True -> simplifile.write(to: destination, contents: serialise(snapshot)) |> result.map_error(CannotSaveNewSnapshot( reason: _, title: snapshot.title, destination:, )) } } /// Read an accepted snapshot which might be missing. /// fn read_accepted(source: String) -> Result(Option(Snapshot(Accepted)), Error) { case simplifile.read(source) { Ok(content) -> case deserialise(content) { Ok(snapshot) -> Ok(Some(snapshot)) Error(Nil) -> Error(CorruptedSnapshot(source)) } Error(Enoent) -> Ok(None) Error(reason) -> Error(CannotReadAcceptedSnapshot(reason:, source:)) } } /// Read a new snapshot. /// /// > ℹ️ Notice the different return type compared to `read_accepted`: when we /// > try to read a new snapshot we are sure it's there (because we've listed /// > the directory or something else) so if it's not present that's an error /// > and we don't return an `Ok(None)`. /// fn read_new(source: String) -> Result(Snapshot(New), Error) { case simplifile.read(source) { Ok(content) -> result.replace_error(deserialise(content), CorruptedSnapshot(source)) Error(reason) -> Error(CannotReadNewSnapshot(reason:, source:)) } } /// List all the new snapshots in a folder. Every file is automatically /// prepended with the folder so you get the full path of each file. /// fn list_new_snapshots(in folder: String) -> Result(List(String), Error) { case simplifile.read_directory(folder) { Error(reason) -> Error(CannotReadSnapshots(reason:, folder:)) Ok(files) -> Ok({ use file <- list.filter_map(files) case filepath.extension(file) { // Only keep the files with the ".new" extension and join their name // with the folder's path. Ok(extension) if extension == new_extension -> Ok(filepath.join(folder, file)) _ -> Error(Nil) } }) } } /// List all the accepted snapshots in a folder. Every file is automatically /// prepended with the folder so you get the full path of each file. /// fn list_accepted_snapshots(in folder: String) -> Result(List(String), Error) { case simplifile.read_directory(folder) { Error(reason) -> Error(CannotReadSnapshots(reason:, folder:)) Ok(files) -> Ok({ use file <- list.filter_map(files) case filepath.extension(file) { // Only keep the files with the ".accepted" extension and join their // name with the folder's path. Ok(extension) if extension == accepted_extension -> Ok(filepath.join(folder, file)) _ -> Error(Nil) } }) } } fn accept_snapshot( new_snapshot_path: String, analyser: Analyser, ) -> Result(Nil, Error) { use snapshot <- result.try(read_new(new_snapshot_path)) let Snapshot(title:, content:, info: _) = snapshot let accepted_snapshot_path = to_accepted_path(new_snapshot_path) // Once a snapshot is accepted we need to mark it as referenced. Otherwise // running `gleam run -m birdie accept` (or `review`) followed by // `gleam run -m stale check` would result in all those accepted snapshots // being marked as stale! use referenced_file <- result.try(referenced_file_path()) use _ <- result.try(case simplifile.is_file(referenced_file) { Ok(_) -> Ok(Nil) Error(_) -> simplifile.create_file(referenced_file) |> result.map_error(CannotCreateReferencedFile( file: referenced_file, reason: _, )) }) use _ <- result.try( simplifile.append( filepath.base_name(accepted_snapshot_path) <> "\n", to: referenced_file, ) |> result.map_error(CannotMarkSnapshotAsReferenced), ) case get_info_for_snapshot(analyser, titled: title) { // We could find additional info about the test so we add it to the snapshot // before saving it! So we delete the `new` file and write an `accepted` // one with all the new info we found. Ok(info) -> { use _ <- result.try( simplifile.delete(new_snapshot_path) |> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path)), ) Snapshot(title:, content:, info: Some(info)) |> serialise |> simplifile.write(to: accepted_snapshot_path) |> result.map_error(CannotAcceptSnapshot(_, accepted_snapshot_path)) } // If there's no snapshot with this title, or there's multiple ones (that's // an error!) we don't have any way of linking additional information to // this snapshot test. // So we can just move the `new` snapshot to the `accepted` one. Error(_) -> simplifile.rename(new_snapshot_path, accepted_snapshot_path) |> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path)) } } fn reject_snapshot(new_snapshot_path: String) -> Result(Nil, Error) { simplifile.delete(new_snapshot_path) |> result.map_error(CannotRejectSnapshot(_, new_snapshot_path)) } // --- UTILITIES --------------------------------------------------------------- /// Turns a snapshot's title into a file name stripping it of all dangerous /// characters (or at least those I could think ok 😁). /// fn file_name(title: String) -> String { string.replace(each: "/", with: " ", in: title) |> string.replace(each: "\\", with: " ") |> string.replace(each: "\n", with: " ") |> string.replace(each: "\t", with: " ") |> string.replace(each: "\r", with: " ") |> string.replace(each: ".", with: " ") |> string.replace(each: ":", with: " ") |> justin.snake_case } /// Returns the path where a new snapshot should be saved. /// fn new_destination(snapshot: Snapshot(New), folder: String) -> String { filepath.join(folder, file_name(snapshot.title)) <> "." <> new_extension } /// Turns a new snapshot path into the path of the corresponding accepted /// snapshot. /// fn to_accepted_path(file: String) -> String { // This just replaces the `.new` extension with the `.accepted` one. filepath.strip_extension(file) <> "." <> accepted_extension } // --- PRETTY PRINTING --------------------------------------------------------- fn to_diagnostic(error: Error) -> List(diagnostic.Diagnostic) { // Produces a diagnostic with no label and an error level let error_diagnostic = fn(title, text) { [ diagnostic.Diagnostic( level: diagnostic.Erro, label: None, hint: None, title:, text:, ), ] } case error { SnapshotWithEmptyTitle -> error_diagnostic( "snapshot with empty title", "A snapshot cannot have an empty title.", ) CannotCreateSnapshotsFolder(reason:) -> error_diagnostic( "cannot create snapshot folder", "An unexpected error happened: " <> simplifile.describe_error(reason) <> ".", ) CannotReadAcceptedSnapshot(reason:, source:) -> error_diagnostic( "cannot read accepted snapshot", "An unexpected error happened trying to read " <> ansi.italic("\"" <> source <> "\":") <> " " <> simplifile.describe_error(reason) <> ".", ) CannotReadNewSnapshot(reason:, source:) -> error_diagnostic( "cannot read new snapshot", "An unexpected error happened trying to read " <> ansi.italic("\"" <> source <> "\": ") <> simplifile.describe_error(reason) <> ".", ) CannotSaveNewSnapshot(reason:, title:, destination:) -> error_diagnostic( "cannot save new snapshot", "An unexpected error happened trying to save " <> ansi.italic("\"" <> title <> "\"") <> " to " <> ansi.italic("\"" <> destination <> "\": ") <> simplifile.describe_error(reason) <> ".", ) CannotReadSnapshots(reason:, folder: _) -> error_diagnostic( "cannot read snapshots folder", "An unexpected error happened trying to read the snapshots folder: " <> simplifile.describe_error(reason) <> ".", ) CannotRejectSnapshot(reason:, snapshot:) -> error_diagnostic( "cannot reject snapshot", "An unexpected error happened trying to reject " <> ansi.italic("\"" <> snapshot <> "\": ") <> simplifile.describe_error(reason) <> ".", ) CannotAcceptSnapshot(reason:, snapshot:) -> error_diagnostic( "cannot accept snapshot", "An unexpected error happened trying to accept " <> ansi.italic("\"" <> snapshot <> "\": ") <> simplifile.describe_error(reason) <> ".", ) CannotReadUserInput -> error_diagnostic("cannot read user input", "") CorruptedSnapshot(source:) -> [ diagnostic.Diagnostic( level: diagnostic.Erro, title: "corrupted snapshot", label: None, text: "It looks like " <> ansi.italic("\"" <> source <> "\" ") <> "is not a valid snapshot.\n" <> "This might happen when someone modifies its content.", hint: Some("try deleting the snapshot and recreating it."), ), ] CannotCreateReferencedFile(file:, reason: simplifile.Eacces) -> [ diagnostic.Diagnostic( level: diagnostic.Erro, title: "missing permission to create reference file", label: None, text: "I don't have the required permission to create the file used to track\n" <> { "stale snapshots at: `" <> file <> "`.\n" } <> "This usually happens when the current user doesn't have a write\n" <> "permission for the system's temporary directory.", hint: Some( "you can set the $TEMP environment variable to make me use a\n" <> "different directory to write the reference file in.", ), ), ] CannotReadReferencedFile(file:, reason: simplifile.Eacces) -> [ diagnostic.Diagnostic( level: diagnostic.Erro, title: "missing permission to read reference file", label: None, text: "I don't have the required permission to read the file used to track\n" <> { "stale snapshots at: `" <> file <> "`.\n" } <> "This usually happens when the current user doesn't have a read\n" <> "permission for the system's temporary directory.", hint: Some( "you can set the $TEMP environment variable to make me use a\n" <> "different directory to write the reference file in.", ), ), ] CannotCreateReferencedFile(file: _, reason:) -> error_diagnostic( "cannot create reference file", "An unexpected error happened trying to create the file used to track stale snapshot: " <> simplifile.describe_error(reason) <> ".", ) CannotReadReferencedFile(file: _, reason:) -> error_diagnostic( "cannot read reference file", "An unexpected error happened trying to read the file used to track stale snapshot: " <> simplifile.describe_error(reason) <> ".", ) CannotMarkSnapshotAsReferenced(reason:) -> error_diagnostic( "cannot mark snapshot as referenced", "An unexpected error happened trying to mark a snapshot as referenced: " <> simplifile.describe_error(reason) <> ".", ) CannotFindProjectRoot(reason:) -> error_diagnostic( "cannot find project root", "An unexpected error happened trying to locate the project's root: " <> simplifile.describe_error(reason) <> ".", ) MissingReferencedFile -> { [ diagnostic.Diagnostic( level: diagnostic.Erro, title: "missing stale snapshot file", label: None, text: "I couldn't find any information about stale snapshots.", hint: Some( "remember you have to run `gleam test` first, so I can find any stale snapshot.", ), ), ] } StaleSnapshotsFound(stale_snapshots:) -> { let titles = list.map(stale_snapshots, fn(snapshot) { " - " <> filepath.strip_extension(snapshot) }) |> string.join(with: "\n") let text = "I found the following stale snapshots:\n\n" <> titles <> "\n\n" <> "These snapshots were not referenced by any snapshot test during the " <> "last `gleam test`\n" [ diagnostic.Diagnostic( level: diagnostic.Erro, title: "stale snapshot found", label: None, text:, hint: Some("run `gleam run -m birdie stale delete` to delete them"), ), ] } CannotDeleteStaleSnapshot(reason:) -> error_diagnostic( "cannot delete stale snapshot", "An unexpected error happened trying to delete a stale snapshot: " <> simplifile.describe_error(reason) <> ".", ) CannotReadTestDirectory(reason:) -> error_diagnostic( "cannot read test directroy", "An unexpected error happened trying to read the constents of the test directory: " <> simplifile.describe_error(reason) <> ".", ) CannotFigureOutProjectName(reason:) -> error_diagnostic( "cannot figure out project's name", "An unexpected error happened trying to figure out the project's name: " <> simplifile.describe_error(reason) <> ".", ) CannotReadTestFile(reason:, file:) -> error_diagnostic( "cannot read test file", "An unexpected error happened trying to read " <> ansi.italic("\"" <> file <> "\": ") <> simplifile.describe_error(reason) <> ".", ) CannotMigrateBirdieSnapshotDirectory(reason:, from:, to:) -> error_diagnostic( "cannot migrate snapshot directory", "An unexpected error happened when trying to migrate\n" <> ansi.italic("\"" <> from <> "\" to ") <> ansi.italic("\"" <> to <> "\"\n") <> "The error is: " <> simplifile.describe_error(reason), ) AnalysisError(errors) -> list.map(errors, analyser.error_to_diagnostic) } } type InfoLine { InfoLineWithTitle(content: String, split: Split, title: String) InfoLineWithNoTitle(content: String, split: Split) } type Split { DoNotSplit SplitWords Truncate } fn snapshot_default_lines(snapshot: Snapshot(status)) -> List(InfoLine) { let Snapshot(title:, content: _, info:) = snapshot case info { None -> [InfoLineWithTitle(title, SplitWords, "title")] Some(SnapshotInfo(file:, test_function_name:)) -> [ InfoLineWithTitle(title, SplitWords, "title"), InfoLineWithTitle(file, Truncate, "file"), InfoLineWithTitle(test_function_name, Truncate, "name"), ] } } fn new_snapshot_box( snapshot: Snapshot(New), additional_info_lines: List(InfoLine), ) -> String { let Snapshot(title: _, content:, info: _) = snapshot let content = string.split(content, on: "\n") |> list.index_map(fn(line, i) { DiffLine(number: i + 1, line:, kind: diff.New) }) pretty_box( "new snapshot", content, list.flatten([snapshot_default_lines(snapshot), additional_info_lines]), fn(shared_line) { shared_line }, ) } fn diff_snapshot_box( accepted: Snapshot(Accepted), new: Snapshot(New), additional_info_lines: List(InfoLine), ) -> String { pretty_box( "mismatched snapshots", to_diff_lines(accepted, new), [ snapshot_default_lines(accepted), additional_info_lines, [ InfoLineWithNoTitle("", DoNotSplit), InfoLineWithNoTitle(ansi.red("- old snapshot"), DoNotSplit), InfoLineWithNoTitle(ansi.green("+ new snapshot"), DoNotSplit), ], ] |> list.flatten, fn(shared_line) { ansi.dim(shared_line) }, ) } fn regular_snapshot_box( new: Snapshot(New), additional_info_lines: List(InfoLine), ) { let Snapshot(title: _, content:, info: _) = new let content = string.split(content, on: "\n") |> list.index_map(fn(line, i) { DiffLine(number: i + 1, line:, kind: diff.Shared) }) pretty_box( "mismatched snapshots", content, [snapshot_default_lines(new), additional_info_lines] |> list.flatten, fn(shared_line) { shared_line }, ) } fn count_digits(number: Int) -> Int { count_digits_loop(int.absolute_value(number), 0) } fn count_digits_loop(number: Int, digits: Int) -> Int { case number < 10 { True -> 1 + digits False -> count_digits_loop(number / 10, 1 + digits) } } fn pretty_box( title: String, content_lines: List(DiffLine), info_lines: List(InfoLine), // Determines how a shared diff line is to be displayed shared_line_style: fn(String) -> String, ) -> String { let width = terminal_width() let lines_count = list.length(content_lines) + 1 let padding = count_digits(lines_count) * 2 + 5 // Make the title line. let title_length = string.length(title) let title_line_right = string.repeat("─", width - 5 - title_length) let title_line = "── " <> title <> " ─" <> title_line_right // Make the pretty info lines. let info_lines = list.map(info_lines, pretty_info_line(_, width)) |> string.join("\n") // Add numbers to the content's lines. let content = list.map(content_lines, pretty_diff_line(_, padding, shared_line_style)) |> string.join(with: "\n") // The open and closed delimiters for the box main content. let left_padding_line = string.repeat("─", padding) let right_padding_line = string.repeat("─", width - padding - 1) let open_line = left_padding_line <> "┬" <> right_padding_line let closed_line = left_padding_line <> "┴" <> right_padding_line // Assemble everything together with some empty lines to allow the content to // breath a little. [title_line, "", info_lines, "", open_line, content, closed_line] |> string.join(with: "\n") } fn pretty_info_line(line: InfoLine, width: Int) -> String { let #(prefix, prefix_length) = case line { InfoLineWithNoTitle(..) -> #(" ", 2) InfoLineWithTitle(title:, ..) -> #( " " <> ansi.blue(title <> ": "), string.length(title) + 4, ) } case line.split { Truncate -> prefix <> truncate(line.content, width - prefix_length) DoNotSplit -> prefix <> line.content SplitWords -> case to_lines(line.content, width - prefix_length) { [] -> prefix [line, ..lines] -> { use acc, line <- list.fold(over: lines, from: prefix <> line) acc <> "\n" <> string.repeat(" ", prefix_length) <> line } } } } fn pretty_diff_line( diff_line: DiffLine, padding: Int, shared_line_style: fn(String) -> String, ) -> String { let DiffLine(number:, line:, kind:) = diff_line let #(pretty_number, pretty_line, separator) = case kind { diff.Shared -> #( int.to_string(number) |> string.pad_start(to: padding - 1, with: " ") |> ansi.dim, shared_line_style(line), " │ ", ) diff.New -> #( int.to_string(number) |> string.pad_start(to: padding - 1, with: " ") |> ansi.green |> ansi.bold, ansi.green(line), ansi.green(" + "), ) diff.Old -> { let number = { " " <> int.to_string(number) } |> string.pad_end(to: padding - 1, with: " ") #(ansi.red(number), ansi.red(line), ansi.red(" - ")) } } pretty_number <> separator <> pretty_line } // --- STRING UTILITIES -------------------------------------------------------- fn truncate(string: String, max_length: Int) -> String { case string.length(string) > max_length { False -> string True -> string.to_graphemes(string) |> list.take(max_length - 3) |> string.join(with: "") |> string.append("...") } } fn to_lines(string: String, max_length: Int) -> List(String) { // We still want to keep the original lines, so we work line by line. use line <- list.flat_map(string.split(string, on: "\n")) let words = string.split(line, on: " ") do_to_lines([], "", 0, words, max_length) } fn do_to_lines( lines: List(String), line: String, line_length: Int, words: List(String), max_length: Int, ) -> List(String) { case words { [] -> case line == "" { True -> list.reverse(lines) False -> list.reverse([line, ..lines]) } [word, ..rest] -> { let word_length = string.length(word) let new_line_length = word_length + line_length + 1 // ^ With the +1 we account for the whitespace that separates words! case new_line_length > max_length { True -> do_to_lines([line, ..lines], "", 0, words, max_length) False -> { let new_line = case line { "" -> word _ -> line <> " " <> word } do_to_lines(lines, new_line, new_line_length, rest, max_length) } } } } } // --- CLI COMMAND ------------------------------------------------------------- /// Reviews the snapshots in the project's folder. /// This function will behave differently depending on the command line /// arguments provided to the program. /// To have a look at all the available options you can run /// `gleam run -m birdie help`. /// /// > 🐦‍⬛ The recommended workflow is to first run your gleeunit tests with /// > `gleam test` and then review any new/failing snapshot manually running /// > `gleam run -m birdie`. /// > /// > And don't forget to commit your snapshots! Those should be treated as code /// > and checked with the vcs you're using. /// pub fn main() -> Nil { parse_and_run(argv.load().arguments) } fn parse_and_run(args: List(String)) { case cli.parse(args) { Ok(command) -> run_command(command) Error(UnknownOption(command:, option:)) -> { cli.unknown_option_error(birdie_version, command, option) |> io.println exit(1) } Error(UnknownSubcommand(command:, subcommand:)) -> { cli.unknown_subcommand_error(birdie_version, command, subcommand) |> io.println exit(1) } Error(MissingSubcommand(command:)) -> { cli.missing_subcommand_error(birdie_version, command) |> io.println exit(1) } Error(UnexpectedArgument(command:, argument:)) -> { cli.unexpected_argument_error(birdie_version, command, argument) |> io.println exit(1) } Error(UnknownCommand(command:)) -> case cli.similar_command(to: command) { Error(Nil) -> { cli.unknown_command_error(command, True) |> io.println exit(1) } Ok(new_command) -> { cli.unknown_command_error(command, False) |> io.println let prompt = "I think you misspelled `" <> new_command <> "`, would you like me to run it instead?" case ask_yes_or_no(prompt) { No -> { io.println("\n" <> cli.main_help_text()) exit(1) } Yes -> replace_first(command, with: new_command, in: args) |> parse_and_run } } } } } fn ask_yes_or_no(prompt: String) -> Answer { case get_line(prompt <> " [Y/n] ") { Error(_) -> No Ok(line) -> case string.lowercase(line) |> string.trim { "yes" | "y" | "" -> Yes _ -> No } } } type Answer { Yes No } fn run_command(command: Command) -> Nil { case migrate_from_old_directory() { Error(diagnostic) -> report_status(Error(diagnostic)) Ok(_) -> { case command { Review -> report_status(review()) Accept -> report_status(accept_all()) Reject -> report_status(reject_all()) Stale(CheckStale) -> report_status(check_stale()) Stale(DeleteStale) -> report_status(delete_stale()) Help -> io.println(cli.help_text( birdie_version, for: Help, explaining: FullCommand, )) WithHelpOption(command:, explained:) -> io.println(cli.help_text( birdie_version, for: command, explaining: explained, )) } } } } fn migrate_from_old_directory() -> Result(Nil, Error) { // We're not using the snapshot_folder function, because we don't want to // create that directroy automatically if it doesn't exist! use snapshot_folder <- result.try(snapshot_folder_name()) use legacy_snapshot_folder <- result.try(legacy_snapshot_folder_name()) case simplifile.is_directory(legacy_snapshot_folder) { // If the legacy directory doesn't exist, or it's not a directory at all // there's no need to do anything. Error(simplifile.Enoent) -> Ok(Nil) Ok(False) -> Ok(Nil) Error(reason) -> Error(CannotReadSnapshots(reason:, folder: legacy_snapshot_folder)) Ok(True) -> { diagnostic.Diagnostic( level: diagnostic.Warn, title: "moved snapshots directory", label: None, hint: None, text: "Starting from 1.6 birdie is using the `test/birdie_snapshots` directory to store snapshot tests, so `birdie_snapshots` was moved there.", ) |> diagnostic.to_string |> string.append("\n") |> io.println simplifile.rename(legacy_snapshot_folder, snapshot_folder) |> result.map_error(CannotMigrateBirdieSnapshotDirectory( reason: _, from: legacy_snapshot_folder, to: snapshot_folder, )) } } } fn review() -> Result(Nil, Error) { use snapshots_folder <- result.try(snapshot_folder()) use analyser <- result.try(analyse_test_directory()) use _ <- result.try(update_accepted_snapshots(snapshots_folder, analyser)) // Before reviewing, we want to update the files of all the existing snapshots // because they might have been moved to a different module, changing their // source `file`. use _ <- result.try(do_review(snapshots_folder, analyser)) Ok(Nil) } fn update_accepted_snapshots( snapshots_folder: String, analyser: Analyser, ) -> Result(Nil, Error) { use accepted_snapshots <- result.try(list_accepted_snapshots(snapshots_folder)) use accepted_snapshot <- list.try_each(accepted_snapshots) use snapshot <- result.try(read_accepted(accepted_snapshot)) case snapshot { None -> Ok(Nil) Some(Snapshot(title:, content: _, info: existing_info) as snapshot) -> { case get_info_for_snapshot(analyser, titled: title), existing_info { Ok(new_info), Some(existing_info) if new_info != existing_info -> Snapshot(..snapshot, info: Some(new_info)) |> serialise |> simplifile.write(to: accepted_snapshot) |> result.map_error(CannotAcceptSnapshot(_, accepted_snapshot)) Ok(info), None -> Snapshot(..snapshot, info: Some(info)) |> serialise |> simplifile.write(to: accepted_snapshot) |> result.map_error(CannotAcceptSnapshot(_, accepted_snapshot)) _, _ -> Ok(Nil) } } } } /// If there's a _single_ snapshot with the given title, this return information /// about it. /// If there's no snapshot, or there's multiple ones then that's an error! We /// can't reliably return information about because it's either missing, or /// there's multiple snapshots sharing the same title and it's impossible to /// know which one we're referring to. fn get_info_for_snapshot( analyser: Analyser, titled title: String, ) -> Result(SnapshotInfo, Nil) { case analyser.get_snapshot_tests(analyser, titled: title) { [] | [_, _, ..] -> Error(Nil) [#(uri, analyser.SnapshotTest(test_function_name:, ..))] -> Ok(SnapshotInfo(file: uri.path, test_function_name:)) } } fn do_review( snapshots_folder: String, analyser: Analyser, ) -> Result(Nil, Error) { use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder)) case list.length(new_snapshots) { // If there's no snapshots to review, we're done! 0 -> { io.println("No new snapshots to review.") Ok(Nil) } // If there's snapshots to review start the interactive session. n -> { let result = review_loop(new_snapshots, analyser, 1, n, ShowDiff) // Despite the review process ending well or with an error, we want to // clear the screen of any garbage before showing the error explanation // or the happy completion string. // That's why we postpone the `result.try` step. clear() use _ <- result.try(result) // A nice message based on the number of snapshots :) io.println(case n { 1 -> "Reviewed one snapshot" n -> "Reviewed " <> int.to_string(n) <> " snapshots" }) Ok(Nil) } } } /// Reviews all the new snapshots one by one. fn review_loop( new_snapshot_paths: List(String), analyser: Analyser, current: Int, out_of: Int, mode: ReviewMode, ) -> Result(Nil, Error) { case new_snapshot_paths { [] -> Ok(Nil) [new_snapshot_path, ..rest] -> { clear() // We try reading the new snapshot and the accepted one (which might be // missing). use new_snapshot <- result.try(read_new(new_snapshot_path)) // We need to add to the new test info about its location and the function // it's defined in. let new_snapshot = Snapshot( ..new_snapshot, info: get_info_for_snapshot(analyser, titled: new_snapshot.title) |> option.from_result, ) let accepted_snapshot_path = to_accepted_path(new_snapshot_path) use accepted_snapshot <- result.try(read_accepted(accepted_snapshot_path)) let progress = ansi.dim("Reviewing ") <> ansi.bold(ansi.yellow(rank.ordinalise(current))) <> ansi.dim(" out of ") <> ansi.bold(ansi.yellow(int.to_string(out_of))) // If there's no accepted snapshot then we're just reviewing a new // snapshot. Otherwise we show a nice diff. let box = case accepted_snapshot, mode { None, _ -> new_snapshot_box(new_snapshot, []) Some(accepted_snapshot), ShowDiff -> diff_snapshot_box(accepted_snapshot, new_snapshot, []) Some(_accepted_snapshot), HideDiff -> regular_snapshot_box(new_snapshot, []) } io.println(progress <> "\n\n" <> box <> "\n") // We ask the user what to do with this snapshot. use choice <- result.try(ask_choice(mode)) case choice { AcceptSnapshot -> { use _ <- result.try(accept_snapshot(new_snapshot_path, analyser)) review_loop(rest, analyser, current + 1, out_of, mode) } RejectSnapshot -> { use _ <- result.try(reject_snapshot(new_snapshot_path)) review_loop(rest, analyser, current + 1, out_of, mode) } SkipSnapshot -> { review_loop(rest, analyser, current + 1, out_of, mode) } ToggleDiffView -> { let mode = toggle_mode(mode) review_loop(new_snapshot_paths, analyser, current, out_of, mode) } } } } } /// Wether or not we should be showing a diff during the current review process. /// type ReviewMode { ShowDiff HideDiff } fn toggle_mode(mode: ReviewMode) -> ReviewMode { case mode { ShowDiff -> HideDiff HideDiff -> ShowDiff } } /// The choice the user can make when reviewing a snapshot. /// type ReviewChoice { AcceptSnapshot RejectSnapshot SkipSnapshot ToggleDiffView } /// Asks the user to make a choice: it first prints a reminder of the options /// and waits for the user to choose one. /// Will prompt again if the choice is not amongst the possible options. /// fn ask_choice(mode: ReviewMode) -> Result(ReviewChoice, Error) { let diff_message = case mode { HideDiff -> " show diff " ShowDiff -> " hide diff " } io.println( { ansi.bold(ansi.green(" a")) <> " accept " <> ansi.dim("accept the new snapshot\n") } <> { ansi.bold(ansi.red(" r")) <> " reject " <> ansi.dim("reject the new snapshot\n") } <> { ansi.bold(ansi.yellow(" s")) <> " skip " <> ansi.dim("skip the snapshot for now\n") } <> { ansi.bold(ansi.cyan(" d")) <> diff_message <> ansi.dim("toggle snapshot diff\n") }, ) // We clear the line of any possible garbage that might still be there from // a previous prompt of the same method. clear_line() case result.map(get_line("> "), string.trim) { Ok("a") -> Ok(AcceptSnapshot) Ok("r") -> Ok(RejectSnapshot) Ok("s") -> Ok(SkipSnapshot) Ok("d") -> Ok(ToggleDiffView) // If the choice is not one of the proposed ones we move the cursor back to // the top of where it was and print everything once again, asking for a // valid option. Ok(_) -> { cursor_up(6) ask_choice(mode) } Error(_) -> Error(CannotReadUserInput) } } fn accept_all() -> Result(Nil, Error) { io.println("Looking for new snapshots...") use snapshots_folder <- result.try(snapshot_folder()) use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder)) use analyser <- result.try(analyse_test_directory()) use _ <- result.try(update_accepted_snapshots(snapshots_folder, analyser)) case list.length(new_snapshots) { 0 -> io.println("No new snapshots to accept.") 1 -> io.println("Accepting one new snapshot.") n -> io.println("Accepting " <> int.to_string(n) <> " new snapshots.") } list.try_each(new_snapshots, accept_snapshot(_, analyser)) } fn reject_all() -> Result(Nil, Error) { io.println("Looking for new snapshots...") use snapshots_folder <- result.try(snapshot_folder()) use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder)) use analyser <- result.try(analyse_test_directory()) use _ <- result.try(update_accepted_snapshots(snapshots_folder, analyser)) case list.length(new_snapshots) { 0 -> io.println("No new snapshots to reject.") 1 -> io.println("Rejecting one new snapshot.") n -> io.println("Rejecting " <> int.to_string(n) <> " new snapshots.") } list.try_each(new_snapshots, reject_snapshot) } /// This finds the current Gleam project's test directory and analyses all the /// modules inside to find snapshot tests and information related to them. /// This could fail under different circumstances: /// - If the file system operations (like reading) fail, should technically /// never happen in a normal scenario /// - OR if the test directory contains snapshots with duplicate titles! /// This is something that could happen and we need to show a nice error /// message. fn analyse_test_directory() -> Result(Analyser, Error) { use root <- result.try( project.find_root() |> result.map_error(CannotFindProjectRoot), ) use files <- result.try( filepath.join(root, "test") |> simplifile.get_files |> result.map_error(CannotReadTestDirectory), ) use analyser <- result.try( list.try_fold(over: files, from: analyser.new(), with: fn(analyser, file) { // If the file is not a gleam file, we just keep going... let is_gleam_file = filepath.extension(file) == Ok("gleam") use <- bool.guard(when: !is_gleam_file, return: Ok(analyser)) //...otherwise we try and read its content and analyse it use source <- result.try( simplifile.read(file) |> result.map_error(CannotReadTestFile(_, file)), ) let path = filepath_to_uri(file) Ok(analyser.analyse(analyser, analyser.Module(path:, source:))) }), ) // If we could successfully read all the modules, now we can check for errors. // TODO)) What about warnings? case analyser.errors(analyser) { [] -> Ok(analyser) [_, ..] as errors -> Error(AnalysisError(errors)) } } fn filepath_to_uri(path: String) -> uri.Uri { uri.Uri( scheme: Some("file"), userinfo: None, host: None, port: None, path:, query: None, fragment: None, ) } fn stale_snapshots_file_names() -> Result(List(String), Error) { use snapshots_folder <- result.try(snapshot_folder()) use referenced_file <- result.try(referenced_file_path()) case simplifile.read(referenced_file) { // If the file is not there we just give up. It means that we didn't run // `gleam test` beforehand. Error(Enoent) -> Error(MissingReferencedFile) // If the file cannot be read for any other reason we end up reporting the // error. Error(reason) -> Error(CannotReadReferencedFile(file: referenced_file, reason:)) // Otherwise we can continue checking! Ok(non_stale_snapshots) -> { let existing_accepted_snapshots = simplifile.get_files(in: snapshots_folder) |> result.unwrap(or: []) |> list.fold(from: set.new(), with: fn(files, file) { case filepath.extension(file) == Ok(accepted_extension) { True -> set.insert(files, filepath.base_name(file)) False -> files } }) let non_stale_snapshots = string.split(non_stale_snapshots, on: "\n") existing_accepted_snapshots |> set.drop(non_stale_snapshots) |> set.to_list |> Ok } } } fn check_stale() -> Result(Nil, Error) { io.println("Checking stale snapshots...") use stale_snapshots <- result.try(stale_snapshots_file_names()) case stale_snapshots { [] -> Ok(Nil) [_, ..] -> Error(StaleSnapshotsFound(stale_snapshots:)) } } fn delete_stale() -> Result(Nil, Error) { io.println("Checking stale snapshots...") use snapshots_folder <- result.try(snapshot_folder()) use stale_snapshots <- result.try(stale_snapshots_file_names()) list.try_each(stale_snapshots, fn(stale_snapshot) { filepath.join(snapshots_folder, stale_snapshot) |> simplifile.delete }) |> result.map_error(CannotDeleteStaleSnapshot(reason: _)) } fn report_status(result: Result(Nil, Error)) -> Nil { case result { Ok(Nil) -> { io.println(ansi.green("🐦‍⬛ Done!")) exit(0) } Error(error) -> { to_diagnostic(error) |> list.map(diagnostic.to_string) |> string.join("\n\n") |> io.println_error exit(1) } } } fn terminal_width() -> Int { case term_size.get() { Ok(#(_, columns)) -> columns Error(_) -> 80 } } // --- HELPERS ----------------------------------------------------------------- /// Replaces the first occurrence of an element in the list with the given /// replacement. /// fn replace_first( in list: List(a), item item: a, with replacement: a, ) -> List(a) { case list { [] -> [] [first, ..rest] if first == item -> [replacement, ..rest] [first, ..rest] -> [first, ..replace_first(rest, item, replacement)] } } /// Clear the screen. /// fn clear() -> Nil { io.print("\u{1b}c") io.print("\u{1b}[H\u{1b}[J") } /// Move the cursor up a given number of lines. /// fn cursor_up(n: Int) -> Nil { io.print("\u{1b}[" <> int.to_string(n) <> "A") } /// Clear the line the cursor is currently on. /// fn clear_line() -> Nil { io.print("\u{1b}[2K") } // --- FFI --------------------------------------------------------------------- @external(erlang, "erlang", "halt") @external(javascript, "./birdie_ffi.mjs", "halt") fn exit(status_code: Int) -> Nil /// Reads a line from standard input with the given prompt. /// /// # Example /// /// ```gleam /// get_line("Language: ") /// // > Language: <- Gleam /// // -> Ok("Gleam\n") /// ``` @external(erlang, "birdie_ffi", "get_line") @external(javascript, "./birdie_ffi.mjs", "get_line") fn get_line(prompt prompt: String) -> Result(String, Nil) ================================================ FILE: src/birdie_ffi.erl ================================================ -module(birdie_ffi). -export([get_line/1, is_windows/0]). get_line(Prompt) -> case io:get_line(Prompt) of eof -> {error, nil}; {error, _} -> {error, nil}; Data when is_binary(Data) -> {ok, Data}; Data when is_list(Data) -> {ok, unicode:characters_to_binary(Data)} end. is_windows() -> case os:type() of {win32, _} -> true; _ -> false end. ================================================ FILE: src/birdie_ffi.mjs ================================================ import fs from "node:fs"; import { exit } from "node:process"; import { Result$Ok, Result$Error } from "./gleam.mjs"; export function is_windows() { return globalThis?.process?.platform === "win32" || globalThis?.Deno?.build?.os === "windows"; } export function halt(status_code) { exit(status_code); } export function get_line(prompt) { process.stdout.write(prompt); const buffer = Buffer.alloc(4096); while (true) { try { const bytesRead = fs.readSync(process.stdin.fd, buffer, 0, buffer.length, null); const input = buffer.toString("utf-8", 0, bytesRead); return Result$Ok(input); } catch (error) { if (error.code === "EAGAIN") continue; return Result$Error(undefined); } } } ================================================ FILE: test/birdie_snapshots/a_result_test.accepted ================================================ --- version: 1.6.0 title: a result test file: ./test/birdie_test.gleam test_name: a_result_test --- Ok(11) ================================================ FILE: test/birdie_snapshots/complex_function_test.accepted ================================================ --- version: 1.6.0 title: complex function test file: ./test/birdie_test.gleam test_name: complex_function_test --- case wibble(wobble, woo) { True -> io.println("Phew, we don't have to launch the missiles...") False -> { io.println("Not wibble!") launch_missiles() } } ================================================ FILE: test/birdie_snapshots/hello_birdie_test.accepted ================================================ --- version: 1.6.0 title: hello birdie test file: ./test/birdie_test.gleam test_name: hello_birdie_test --- 🐦‍⬛ smile for the birdie! ================================================ FILE: test/birdie_snapshots/list_test.accepted ================================================ --- version: 1.6.0 title: list test file: ./test/birdie_test.gleam test_name: list_test --- [ 1, 2, 3, 4 ] ================================================ FILE: test/birdie_snapshots/multi_line_diagnostic.accepted ================================================ --- version: 1.6.0 title: multi line diagnostic file: ./test/birdie_test/diagnostic_test.gleam test_name: multi_line_diagnostic_test --- error: example  ╭─ wibble.src  │ 1 │ line here 2 │ here  │ ^^^^^^^^^ message  │ ================================================ FILE: test/birdie_snapshots/single_line_diagnostic_with_no_tooltip_text.accepted ================================================ --- version: 1.6.0 title: single line diagnostic with no tooltip text file: ./test/birdie_test/diagnostic_test.gleam test_name: single_line_diagnostic_with_no_tooltip_text_test --- error: example  ╭─ wibble.src  │ 1 │ highlighted-not-highlighted  │ ^^^^^^^^^^^  │ ================================================ FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_above.accepted ================================================ --- version: 1.6.0 title: single line diagnostic with secondary label above file: ./test/birdie_test/diagnostic_test.gleam test_name: single_line_diagnostic_with_secondary_label_above_test --- error: example  ╭─ wibble.src  │ 1 │ secondary here  │ ~~~~  ╎ 3 │ main  │ ^^^^  │ ================================================ FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_below.accepted ================================================ --- version: 1.6.0 title: single line diagnostic with secondary label below file: ./test/birdie_test/diagnostic_test.gleam test_name: single_line_diagnostic_with_secondary_label_below_test --- error: example  ╭─ wibble.src  │ 1 │ main  │ ^^^^ primary  ╎ 3 │ secondary here  │ ~~~~ secondary  │ ================================================ FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_on_same_line,_secondary_label_is_ignored.accepted ================================================ --- version: 1.6.0 title: single line diagnostic with secondary label on same line, secondary label is ignored file: ./test/birdie_test/diagnostic_test.gleam test_name: single_line_diagnostic_with_secondary_label_on_same_line_test --- error: example  ╭─ wibble.src  │ 1 │ secondary primary  │ ^^^^^^^ shown  │ ================================================ FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_right_above.accepted ================================================ --- version: 1.6.0 title: single line diagnostic with secondary label right above file: ./test/birdie_test/diagnostic_test.gleam test_name: single_line_diagnostic_with_secondary_label_right_above_test --- error: example  ╭─ wibble.src  │ 1 │ secondary here  │ ~~~~ 2 │ main  │ ^^^^ primary  │ ================================================ FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_right_below.accepted ================================================ --- version: 1.6.0 title: single line diagnostic with secondary label right below file: ./test/birdie_test/diagnostic_test.gleam test_name: single_line_diagnostic_with_secondary_label_right_below_test --- warning: example  ╭─ wibble.src  │ 1 │ main  │ ^^^^ primary 2 │ secondary here  │ ~~~~ secondary  │ ================================================ FILE: test/birdie_snapshots/single_line_diagnostic_with_tooltip_text.accepted ================================================ --- version: 1.6.0 title: single line diagnostic with tooltip text file: ./test/birdie_test/diagnostic_test.gleam test_name: single_line_diagnostic_with_tooltip_text_test --- error: example  ╭─ wibble.src  │ 1 │ highlighted-not-highlighted  │ ^^^^^^^^^^^ not good  │ ================================================ FILE: test/birdie_test/cli_test.gleam ================================================ import birdie/internal/cli.{ type Command, Accept, CheckStale, DeleteStale, FullCommand, Help, Reject, Review, Stale, TopLevelCommand, UnexpectedArgument, UnknownCommand, UnknownOption, UnknownSubcommand, WithHelpOption, } import gleam/list import gleam/string pub fn unknown_top_level_command_test() { assert Error(UnknownCommand(command: "wibble")) == cli.parse(["wibble"]) assert Error(UnknownCommand(command: "wibble")) == cli.parse(["wibble", "wobble"]) assert Error(UnknownCommand(command: "wibble")) == cli.parse(["wibble", "--help"]) assert Error(UnknownCommand(command: "wibble")) == cli.parse(["wibble", "-h"]) } pub fn parse_review_test() { // No arguments are interpreted as the review command assert Ok(Review) == cli.parse([]) assert Ok(WithHelpOption(Review, FullCommand)) == cli.parse(["--help"]) assert Ok(WithHelpOption(Review, FullCommand)) == cli.parse(["-h"]) // Explicitly using the review command assert Ok(Review) == cli.parse(["review"]) assert Ok(WithHelpOption(Review, FullCommand)) == cli.parse(["review", "--help"]) assert Ok(WithHelpOption(Review, FullCommand)) == cli.parse(["review", "-h"]) // Unknown subcommands and options assert Error(UnknownSubcommand(Review, "wibble")) == cli.parse(["review", "wibble"]) assert Error(UnknownOption(Review, "-w")) == cli.parse(["-w"]) assert Error(UnknownOption(Review, "-w")) == cli.parse(["review", "-w"]) assert Error(UnknownOption(Review, "--wibble")) == cli.parse(["--wibble"]) assert Error(UnknownOption(Review, "--wibble")) == cli.parse(["review", "--wibble"]) } pub fn parse_accept_test() { // Explicitly using the review command assert Ok(Accept) == cli.parse(["accept"]) assert Ok(WithHelpOption(Accept, FullCommand)) == cli.parse(["accept", "--help"]) assert Ok(WithHelpOption(Accept, FullCommand)) == cli.parse(["accept", "-h"]) // Unknown subcommands and options assert Error(UnknownSubcommand(Accept, "wibble")) == cli.parse(["accept", "wibble"]) assert Error(UnknownOption(Accept, "-w")) == cli.parse(["accept", "-w"]) assert Error(UnknownOption(Accept, "--wibble")) == cli.parse(["accept", "--wibble"]) } pub fn parse_reject_test() { // Explicitly using the review command assert Ok(Reject) == cli.parse(["reject"]) assert Ok(WithHelpOption(Reject, FullCommand)) == cli.parse(["reject", "--help"]) assert Ok(WithHelpOption(Reject, FullCommand)) == cli.parse(["reject", "-h"]) // Unknown subcommands and options assert Error(UnknownSubcommand(Reject, "wibble")) == cli.parse(["reject", "wibble"]) assert Error(UnknownOption(Reject, "-w")) == cli.parse(["reject", "-w"]) assert Error(UnknownOption(Reject, "--wibble")) == cli.parse(["reject", "--wibble"]) } pub fn parse_stale_test() { let assert Ok(WithHelpOption(Stale(_), TopLevelCommand)) = cli.parse(["stale", "--help"]) let assert Ok(WithHelpOption(Stale(_), TopLevelCommand)) = cli.parse(["stale", "-h"]) assert Ok(Stale(CheckStale)) == cli.parse(["stale", "check"]) assert Ok(WithHelpOption(Stale(CheckStale), FullCommand)) == cli.parse(["stale", "check", "--help"]) assert Ok(Stale(DeleteStale)) == cli.parse(["stale", "delete"]) assert Ok(WithHelpOption(Stale(DeleteStale), FullCommand)) == cli.parse(["stale", "delete", "--help"]) // Unknown subcommands and options let assert Error(UnknownSubcommand(Stale(_), "wibble")) = cli.parse(["stale", "wibble"]) assert Error(UnexpectedArgument(Stale(CheckStale), "wibble")) == cli.parse(["stale", "check", "wibble"]) assert Error(UnexpectedArgument(Stale(DeleteStale), "wibble")) == cli.parse(["stale", "delete", "wibble"]) let assert Error(UnknownOption(Stale(_), "-w")) = cli.parse(["stale", "-w"]) let assert Error(UnknownOption(Stale(_), "--wibble")) = cli.parse(["stale", "--wibble"]) assert Error(UnknownOption(Stale(CheckStale), "-w")) == cli.parse(["stale", "check", "-w"]) assert Error(UnknownOption(Stale(CheckStale), "--wibble")) == cli.parse(["stale", "check", "--wibble"]) assert Error(UnknownOption(Stale(DeleteStale), "-w")) == cli.parse(["stale", "delete", "-w"]) assert Error(UnknownOption(Stale(DeleteStale), "--wibble")) == cli.parse(["stale", "delete", "--wibble"]) } pub fn parse_help_test() { // Explicitly using the review command assert Ok(Help) == cli.parse(["help"]) assert Ok(Help) == cli.parse(["help", "--help"]) assert Ok(Help) == cli.parse(["help", "-h"]) } pub fn all_commands_test() { assert cli.all_commands() == all_known_commands([]) } fn all_known_commands(all: List(Command)) -> List(String) { case all { [] -> all_known_commands([Accept, ..all]) [Accept, ..] -> all_known_commands([Help, ..all]) [Help, ..] -> all_known_commands([Reject, ..all]) [Reject, ..] -> all_known_commands([Stale(CheckStale), ..all]) [Stale(_), ..] -> all_known_commands([Review, ..all]) [Review, ..] -> list.map(all, command_to_string) |> list.sort(string.compare) [WithHelpOption(..), ..] -> panic as "this command is never suggested" } } fn command_to_string(command: Command) { case command { Accept -> "accept" Help -> "help" Reject -> "reject" Review -> "review" Stale(_) -> "stale" WithHelpOption(..) -> panic as "this command is never suggested" } } ================================================ FILE: test/birdie_test/diagnostic_test.gleam ================================================ import birdie import birdie/internal/diagnostic import glance import gleam/option pub fn single_line_diagnostic_with_no_tooltip_text_test() { diagnostic.Diagnostic( level: diagnostic.Erro, title: "example", label: option.Some(diagnostic.Label( file_name: "wibble.src", source: "highlighted-not-highlighted\nnot shown", position: glance.Span(0, 11), content: "", secondary_label: option.None, )), text: "", hint: option.None, ) |> diagnostic.to_string |> birdie.snap(title: "single line diagnostic with no tooltip text") } pub fn single_line_diagnostic_with_tooltip_text_test() { diagnostic.Diagnostic( level: diagnostic.Erro, title: "example", label: option.Some(diagnostic.Label( file_name: "wibble.src", source: "highlighted-not-highlighted\nnot shown", position: glance.Span(0, 11), content: "not good", secondary_label: option.None, )), text: "", hint: option.None, ) |> diagnostic.to_string |> birdie.snap(title: "single line diagnostic with tooltip text") } pub fn single_line_diagnostic_with_secondary_label_right_above_test() { diagnostic.Diagnostic( level: diagnostic.Erro, title: "example", label: option.Some(diagnostic.Label( file_name: "wibble.src", source: "secondary here\nmain", position: glance.Span(15, 19), content: "primary", secondary_label: option.Some(#(glance.Span(10, 14), "")), )), text: "", hint: option.None, ) |> diagnostic.to_string |> birdie.snap( title: "single line diagnostic with secondary label right above", ) } pub fn single_line_diagnostic_with_secondary_label_right_below_test() { diagnostic.Diagnostic( level: diagnostic.Warn, title: "example", label: option.Some(diagnostic.Label( file_name: "wibble.src", source: "main\nsecondary here", position: glance.Span(0, 4), content: "primary", secondary_label: option.Some(#(glance.Span(15, 19), "secondary")), )), text: "", hint: option.None, ) |> diagnostic.to_string |> birdie.snap( title: "single line diagnostic with secondary label right below", ) } pub fn single_line_diagnostic_with_secondary_label_below_test() { diagnostic.Diagnostic( level: diagnostic.Erro, title: "example", label: option.Some(diagnostic.Label( file_name: "wibble.src", source: "main\nnot shown\nsecondary here", position: glance.Span(0, 4), content: "primary", secondary_label: option.Some(#(glance.Span(25, 29), "secondary")), )), text: "", hint: option.None, ) |> diagnostic.to_string |> birdie.snap(title: "single line diagnostic with secondary label below") } pub fn single_line_diagnostic_with_secondary_label_above_test() { diagnostic.Diagnostic( level: diagnostic.Erro, title: "example", label: option.Some(diagnostic.Label( file_name: "wibble.src", source: "secondary here\nnot shown\nmain", position: glance.Span(25, 29), content: "", secondary_label: option.Some(#(glance.Span(10, 14), "")), )), text: "", hint: option.None, ) |> diagnostic.to_string |> birdie.snap(title: "single line diagnostic with secondary label above") } pub fn single_line_diagnostic_with_secondary_label_on_same_line_test() { diagnostic.Diagnostic( level: diagnostic.Erro, title: "example", label: option.Some(diagnostic.Label( file_name: "wibble.src", source: "secondary primary", position: glance.Span(10, 17), content: "shown", secondary_label: option.Some(#(glance.Span(0, 9), "not shows")), )), text: "", hint: option.None, ) |> diagnostic.to_string |> birdie.snap( title: "single line diagnostic with secondary label on same line, secondary label is ignored", ) } pub fn multi_line_diagnostic_test() { diagnostic.Diagnostic( level: diagnostic.Erro, title: "example", label: option.Some(diagnostic.Label( file_name: "wibble.src", source: "line here\nhere", position: glance.Span(5, 14), content: "message", secondary_label: option.None, )), text: "", hint: option.None, ) |> diagnostic.to_string |> birdie.snap(title: "multi line diagnostic") } ================================================ FILE: test/birdie_test.gleam ================================================ import birdie import gleam/string import gleeunit pub fn main() { gleeunit.main() } pub fn hello_birdie_test() { "🐦‍⬛ smile for the birdie!" |> birdie.snap(title: "hello birdie test") } pub fn a_result_test() { string.inspect(Ok(11)) |> birdie.snap(title: "a result test") } pub fn list_test() { "[ 1, 2, 3, 4 ]" |> birdie.snap(title: "list test") } pub fn complex_function_test() { "case wibble(wobble, woo) { True -> io.println(\"Phew, we don't have to launch the missiles...\") False -> { io.println(\"Not wibble!\") launch_missiles() } }" |> birdie.snap(title: "complex function test") }